@ata-project/unplugin 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/compile-away.js +103 -7
- package/src/core.js +8 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +64 -2
package/README.md
CHANGED
|
@@ -151,6 +151,7 @@ Other sources (`.json` without the `.schema` suffix, `.js`, `.ts`) produce
|
|
|
151
151
|
| `root` | from the bundler | Where the globs resolve from. Vite's root, Webpack's and Rspack's `context` and esbuild's `absWorkingDir` are read; Rollup and Rolldown need it passed. |
|
|
152
152
|
| `alias` | Vite's `resolve.alias` | Import aliases inside `.ts` schema files. tsconfig `paths` work without configuration. |
|
|
153
153
|
| `compileAway` | `true` | Replace `new Validator(schema)` in your code with a validator compiled at build time, where that gives the same results. See below. Needs ata-validator 1.36.0; with an older version the runtime stays, and a warning is printed only if you set the option yourself. |
|
|
154
|
+
| `sharedErrors` | `'auto'` | Where a compiled validator's error detail comes from. A compact module carries its verdict and its schema and takes its errors from `ata-validator/error-runtime`, which the bundle holds once. `'auto'` scans the project's sources at build start (skipping `node_modules`, build output and test files), adds up what every replaceable call would save, and uses the runtime when the total pays for it; a file the scan did not see decides alone, which takes a schema of several hundred properties. Measured on projects of 1 to 40 SchemaStore schemas, no bundle came out bigger than with `false`, and 40 schemas went from 724 KB to 405 KB gzipped, on ata-validator 1.51.0. `true` writes every schema that allows it compact, and `false` never. The scan adds little to the build, measured in separate processes with esbuild: 84 to 85 ms for three validators, 681 to 715 ms for forty. Errors and verdicts are the same either way; a rejected document costs more, since its errors come from an interpreted walk. Needs ata-validator 1.50.0; with an older one every module stays self-contained, as before. |
|
|
154
155
|
|
|
155
156
|
## compileAway: keep `new Validator`, drop the compiler
|
|
156
157
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ata-project/unplugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Build-time JSON Schema for Vite, Webpack, Rollup, Rolldown, esbuild and Rspack. compileAway takes the runtime compiler out of the bundle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@ata-project/keywords": "^0.3.3",
|
|
81
81
|
"@rspack/core": "^1.7.0",
|
|
82
|
-
"ata-validator": "^1.
|
|
82
|
+
"ata-validator": "^1.51.0",
|
|
83
83
|
"esbuild": "^0.28.0",
|
|
84
84
|
"rolldown": "^1.2.8",
|
|
85
85
|
"rollup": "^4.60.0",
|
package/src/compile-away.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import fs from 'node:fs'
|
|
26
26
|
import path from 'node:path'
|
|
27
|
+
import zlib from 'node:zlib'
|
|
27
28
|
import { parse } from '@babel/parser'
|
|
28
29
|
import MagicString from 'magic-string'
|
|
29
30
|
|
|
@@ -222,8 +223,84 @@ const verdictOnly = (methods) => [...methods].every((m) => VERDICT_METHODS.has(m
|
|
|
222
223
|
|
|
223
224
|
// A verdict-only module hands out isValid alone, so the error function inside
|
|
224
225
|
// it is unreferenced and the bundler drops it.
|
|
226
|
+
// A compact module takes its errors from this runtime, which a bundle holds
|
|
227
|
+
// once. A module is written compact only when it alone saves more than the
|
|
228
|
+
// runtime adds, so the choice never makes a bundle bigger, whatever else the
|
|
229
|
+
// bundle holds and in whatever order files are transformed. The bar is the
|
|
230
|
+
// module's gzipped saving as this transform sees it (unminified source), and
|
|
231
|
+
// it was set from minified esbuild bundles of one schema each: the runtime
|
|
232
|
+
// paid for itself from about 22000 bytes of that saving (a 300-property
|
|
233
|
+
// schema lost 1826 bytes at 19430, a 400-property one gained 1937 at 25127).
|
|
234
|
+
// Below it, a schema stays self-contained; `sharedErrors: true` writes every
|
|
235
|
+
// module compact, for an application with many schemas.
|
|
236
|
+
const ERROR_RUNTIME = 'ata-validator/error-runtime'
|
|
237
|
+
const ERROR_RUNTIME_COST = 24000
|
|
238
|
+
const RUNTIME_IMPORT = /^import \{ createErrors as _ataCreateErrors(?:, compileSafe as _ataCompileSafe)? \} from [^\n]*\n/m
|
|
239
|
+
|
|
240
|
+
// The scan and the transform weigh the same modules, and the module sources
|
|
241
|
+
// come back as the same strings from the session's compile cache: each is
|
|
242
|
+
// gzipped once per build (clearSizes runs at build start).
|
|
243
|
+
const sizes = new Map()
|
|
244
|
+
// The scan parses every source the transform parses again: once per build.
|
|
245
|
+
const asts = new Map()
|
|
246
|
+
function gzipSize(src) {
|
|
247
|
+
let n = sizes.get(src)
|
|
248
|
+
if (n === undefined) { n = zlib.gzipSync(src).length; if (sizes.size > 4096) sizes.clear(); sizes.set(src, n) }
|
|
249
|
+
return n
|
|
250
|
+
}
|
|
251
|
+
export function clearSizes() { sizes.clear(); asts.clear() }
|
|
252
|
+
|
|
253
|
+
// The compact module when it is the choice for `mode`, else null:
|
|
254
|
+
// true wherever the schema allows it
|
|
255
|
+
// 'smaller' where it is smaller than the self-contained module (the build
|
|
256
|
+
// scan found that the application as a whole pays for the runtime)
|
|
257
|
+
// 'auto' where this module alone pays for the runtime
|
|
258
|
+
// false never
|
|
259
|
+
// With `collect`, nothing is chosen: the saving is recorded for the scan.
|
|
260
|
+
function compactModuleFor(schema, full, ata, mode, collect) {
|
|
261
|
+
if (mode === false || !ata.compiledSharedErrors) return null
|
|
262
|
+
let src = null
|
|
263
|
+
try { src = ata.compiledModuleFor(schema, { format: 'esm', errorRuntime: ERROR_RUNTIME }) } catch { src = null }
|
|
264
|
+
if (!src || !RUNTIME_IMPORT.test(src)) return null
|
|
265
|
+
if (mode === true) return src
|
|
266
|
+
// The scan collects the pairs and weighs them all at once, in parallel
|
|
267
|
+
// (sharedErrorsPay); the transform finds the sizes cached.
|
|
268
|
+
if (collect) { collect.push([full, src]); return null }
|
|
269
|
+
const saving = gzipSize(full) - gzipSize(src)
|
|
270
|
+
if (mode === 'smaller') return saving > 0 ? src : null
|
|
271
|
+
return saving > ERROR_RUNTIME_COST ? src : null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Whether an application pays for the shared runtime: the saving of every
|
|
275
|
+
// replaceable call in its sources, against the runtime's cost. `sources` is a
|
|
276
|
+
// list of [code, id]. A call the transform would not replace adds nothing.
|
|
277
|
+
// Gzipping a module of a hundred kilobytes is the scan's largest cost; the
|
|
278
|
+
// asynchronous zlib runs on libuv's thread pool, so the modules of a build
|
|
279
|
+
// are weighed in parallel rather than one after the other.
|
|
280
|
+
function gzipSizeAsync(src) {
|
|
281
|
+
const n = sizes.get(src)
|
|
282
|
+
if (n !== undefined) return Promise.resolve(n)
|
|
283
|
+
return new Promise((resolve, reject) => zlib.gzip(src, (err, out) => {
|
|
284
|
+
if (err) { reject(err); return }
|
|
285
|
+
if (sizes.size > 4096) sizes.clear()
|
|
286
|
+
sizes.set(src, out.length)
|
|
287
|
+
resolve(out.length)
|
|
288
|
+
}))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function sharedErrorsPay(sources, ata) {
|
|
292
|
+
const collect = []
|
|
293
|
+
for (const [code, id] of sources) {
|
|
294
|
+
try { compileAway(code, id, ata, { sharedErrors: 'auto', collect }) } catch {}
|
|
295
|
+
}
|
|
296
|
+
const savings = await Promise.all(collect.map(async ([full, compact]) => (await gzipSizeAsync(full)) - (await gzipSizeAsync(compact))))
|
|
297
|
+
let saving = 0
|
|
298
|
+
for (const x of savings) if (x > 0) saving += x
|
|
299
|
+
return { saving, pays: saving > ERROR_RUNTIME_COST, calls: collect.length }
|
|
300
|
+
}
|
|
301
|
+
|
|
225
302
|
function inlineModule(src, index, verdict) {
|
|
226
|
-
const body = src.split('\n').filter((l) => !/^export\s/.test(l)).join('\n')
|
|
303
|
+
const body = src.replace(RUNTIME_IMPORT, '').split('\n').filter((l) => !/^export\s/.test(l)).join('\n')
|
|
227
304
|
return `const __ataCompiled${index} = (() => {\n${body}\nreturn ${verdict ? '{ isValid }' : '{ validate, isValid }'};\n})();\n`
|
|
228
305
|
}
|
|
229
306
|
|
|
@@ -242,16 +319,26 @@ function compiledOptionsArg(node, ctx, supported) {
|
|
|
242
319
|
return opts.useDefaults === false ? '{"useDefaults":false}' : ''
|
|
243
320
|
}
|
|
244
321
|
|
|
245
|
-
export function compileAway(code, id, ata) {
|
|
322
|
+
export function compileAway(code, id, ata, options) {
|
|
323
|
+
const sharedErrors = options && options.sharedErrors !== undefined ? options.sharedErrors : 'auto'
|
|
324
|
+
const collect = options && Array.isArray(options.collect) ? options.collect : null
|
|
246
325
|
const { compiledModuleFor, compiledSchemaFor } = ata
|
|
247
326
|
const supportedOptions = Array.isArray(ata.compiledOptions) ? ata.compiledOptions : []
|
|
248
327
|
if (!code.includes('ata-validator')) return null
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
328
|
+
// The scan keeps the tree it parsed for the transform, which takes it and
|
|
329
|
+
// lets it go, so a long dev session holds no old versions of a file.
|
|
330
|
+
let ast = asts.get(code)
|
|
331
|
+
if (ast !== undefined) {
|
|
332
|
+
if (!collect) asts.delete(code)
|
|
333
|
+
} else {
|
|
334
|
+
try {
|
|
335
|
+
ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: false })
|
|
336
|
+
} catch {
|
|
337
|
+
ast = null
|
|
338
|
+
}
|
|
339
|
+
if (collect) asts.set(code, ast)
|
|
254
340
|
}
|
|
341
|
+
if (ast === null) return null
|
|
255
342
|
let validatorName = null
|
|
256
343
|
let importDecl = null
|
|
257
344
|
let defineSchema = null
|
|
@@ -295,6 +382,8 @@ export function compileAway(code, id, ata) {
|
|
|
295
382
|
const modules = []
|
|
296
383
|
const done = new Set()
|
|
297
384
|
const wrappers = { full: false, verdict: false }
|
|
385
|
+
let usesRuntime = false
|
|
386
|
+
let usesSafeRe = false
|
|
298
387
|
for (const r of refs) {
|
|
299
388
|
if (r.node.name !== validatorName) continue
|
|
300
389
|
const expr = r.parent
|
|
@@ -314,6 +403,10 @@ export function compileAway(code, id, ata) {
|
|
|
314
403
|
if (!src) continue
|
|
315
404
|
const index = modules.length
|
|
316
405
|
const verdict = ata.compiledVerdict === true && verdictOnly(binding.methods)
|
|
406
|
+
// Code that never reads errors gets the verdict wrapper and no errors at
|
|
407
|
+
// all, so a compact module would only add the runtime.
|
|
408
|
+
const compact = verdict ? null : compactModuleFor(schema, src, ata, sharedErrors, collect)
|
|
409
|
+
if (compact) { src = compact; usesRuntime = true; if (compact.includes('_ataCompileSafe')) usesSafeRe = true }
|
|
317
410
|
modules.push(inlineModule(src, index, verdict))
|
|
318
411
|
const wrap = verdict ? '__ataFromCompiledVerdict' : '__ataFromCompiled'
|
|
319
412
|
if (verdict) wrappers.verdict = true; else wrappers.full = true
|
|
@@ -349,6 +442,9 @@ export function compileAway(code, id, ata) {
|
|
|
349
442
|
for (const call of ctx.tCalls) s.appendLeft(call.start, '/*#__PURE__*/ ')
|
|
350
443
|
if (wrappers.verdict) s.prepend(`import { fromCompiledVerdict as __ataFromCompiledVerdict } from 'ata-validator/compiled-verdict';\n`)
|
|
351
444
|
if (wrappers.full) s.prepend(`import { fromCompiled as __ataFromCompiled } from 'ata-validator/compiled';\n`)
|
|
445
|
+
// compileSafe is the pattern engine a compact module with a pattern the
|
|
446
|
+
// platform RegExp does not run takes from the runtime instead of embedding.
|
|
447
|
+
if (usesRuntime) s.prepend(`import { createErrors as _ataCreateErrors${usesSafeRe ? ', compileSafe as _ataCompileSafe' : ''} } from '${ERROR_RUNTIME}';\n`)
|
|
352
448
|
s.appendLeft(lastImportEnd, '\n' + modules.join(''))
|
|
353
449
|
return { code: s.toString(), map: s.generateMap({ hires: true, source: id, includeContent: true }), replaced: done.size }
|
|
354
450
|
}
|
package/src/core.js
CHANGED
|
@@ -88,8 +88,16 @@ async function loadAta() {
|
|
|
88
88
|
compiledExtendChecks = typeof fromCompiled({ validate: () => ({ valid: true, errors: [] }), isValid: () => true }, {}, { useDefaults: false })._extendChecks === 'function'
|
|
89
89
|
} catch { compiledExtendChecks = false }
|
|
90
90
|
}
|
|
91
|
+
// Compact modules, from ata-validator 1.50.0: the verdict and the schema,
|
|
92
|
+
// with errors from ata-validator/error-runtime, which the bundle then holds
|
|
93
|
+
// once for every compact module in it.
|
|
94
|
+
let compiledSharedErrors = false
|
|
95
|
+
if (canCompileAway) {
|
|
96
|
+
try { compiledSharedErrors = typeof (await import('ata-validator/error-runtime')).createErrors === 'function' } catch { compiledSharedErrors = false }
|
|
97
|
+
}
|
|
91
98
|
return {
|
|
92
99
|
...api,
|
|
100
|
+
compiledSharedErrors,
|
|
93
101
|
toStandaloneModule: build.toStandaloneModule,
|
|
94
102
|
compiledModuleFor: canCompileAway ? build.compiledModuleFor : null,
|
|
95
103
|
compiledSchemaFor: canCompileAway ? build.compiledSchemaFor : null,
|
package/src/index.d.ts
CHANGED
|
@@ -30,6 +30,16 @@ export interface Options {
|
|
|
30
30
|
* Default: `true`; `false` turns it off.
|
|
31
31
|
*/
|
|
32
32
|
compileAway?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Where a replaced validator's error detail comes from. `'auto'` (default)
|
|
35
|
+
* scans the project's sources at build start and writes modules compact
|
|
36
|
+
* (their verdict and schema, with errors from `ata-validator/error-runtime`,
|
|
37
|
+
* held once by the bundle) when all of them together save more than the
|
|
38
|
+
* runtime adds; `true` writes every module that can be compact; `false`
|
|
39
|
+
* never.
|
|
40
|
+
* Needs ata-validator 1.50.0.
|
|
41
|
+
*/
|
|
42
|
+
sharedErrors?: 'auto' | boolean
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export interface CompileResult {
|
package/src/index.js
CHANGED
|
@@ -10,10 +10,50 @@
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import { createUnplugin } from 'unplugin'
|
|
12
12
|
import { createSession, normalizeAlias, compile, __internal } from './core.js'
|
|
13
|
-
import
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import { compileAway, sharedErrorsPay, clearSizes } from './compile-away.js'
|
|
14
15
|
|
|
15
16
|
const SOURCE = /\.[cm]?[jt]sx?$/
|
|
16
17
|
|
|
18
|
+
// Source files the application may import, for the sharedErrors scan: what
|
|
19
|
+
// the transform would see, without node_modules, build output, tests and
|
|
20
|
+
// dot-directories. Only files that mention ata-validator are kept.
|
|
21
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '.git', '.next', '.nuxt', '.output', '.svelte-kit', '.turbo', '.cache'])
|
|
22
|
+
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
23
|
+
function scanSources(root) {
|
|
24
|
+
const out = []
|
|
25
|
+
const walk = (dir, depth) => {
|
|
26
|
+
if (depth > 12) return
|
|
27
|
+
let entries
|
|
28
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
29
|
+
for (const e of entries) {
|
|
30
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
|
|
31
|
+
const p = path.join(dir, e.name)
|
|
32
|
+
if (e.isDirectory()) { if (e.name !== '__tests__' && e.name !== 'test' && e.name !== 'tests') walk(p, depth + 1); continue }
|
|
33
|
+
if (!e.isFile() || !SOURCE.test(e.name) || TEST_FILE.test(e.name)) continue
|
|
34
|
+
let code
|
|
35
|
+
try { if (fs.statSync(p).size > 1 << 20) continue; code = fs.readFileSync(p, 'utf8') } catch { continue }
|
|
36
|
+
if (code.includes('ata-validator')) out.push([code, p])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
walk(root, 0)
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The scan and the transform compile the same schemas: one compile each.
|
|
44
|
+
function cachedApi(session, api) {
|
|
45
|
+
if (session.cachedApi && session.cachedApiFor === api) return session.cachedApi
|
|
46
|
+
const memo = new Map()
|
|
47
|
+
const compiledModuleFor = (schema, opts) => {
|
|
48
|
+
const key = JSON.stringify(schema) + '\u0000' + JSON.stringify(opts || {})
|
|
49
|
+
if (!memo.has(key)) memo.set(key, api.compiledModuleFor(schema, opts))
|
|
50
|
+
return memo.get(key)
|
|
51
|
+
}
|
|
52
|
+
session.cachedApi = { ...api, compiledModuleFor }
|
|
53
|
+
session.cachedApiFor = api
|
|
54
|
+
return session.cachedApi
|
|
55
|
+
}
|
|
56
|
+
|
|
17
57
|
export const unpluginFactory = (userOptions = {}) => {
|
|
18
58
|
const session = createSession(userOptions)
|
|
19
59
|
|
|
@@ -25,6 +65,26 @@ export const unpluginFactory = (userOptions = {}) => {
|
|
|
25
65
|
const { files, results } = await session.compileAll()
|
|
26
66
|
const changed = results.filter((r) => r.changed).length
|
|
27
67
|
session.logger?.info?.(`[unplugin-ata] compiled ${files.length} schema(s), ${changed} file(s) written`)
|
|
68
|
+
// sharedErrors 'auto' decides for the whole application, not file by
|
|
69
|
+
// file: the sources are scanned once here, and the runtime is used when
|
|
70
|
+
// every replaceable call together saves more than it costs.
|
|
71
|
+
session.sharedDecision = undefined
|
|
72
|
+
clearSizes()
|
|
73
|
+
session.scanned = null
|
|
74
|
+
if (userOptions.compileAway !== false && (userOptions.sharedErrors === undefined || userOptions.sharedErrors === 'auto')) {
|
|
75
|
+
const api = await session.api()
|
|
76
|
+
if (api.compiledModuleFor && api.compiledSharedErrors) {
|
|
77
|
+
const sources = scanSources(session.root || process.cwd())
|
|
78
|
+
session.scanned = new Set(sources.map(([, id]) => path.resolve(id)))
|
|
79
|
+
const { pays, saving, calls } = await sharedErrorsPay(sources, cachedApi(session, api))
|
|
80
|
+
// When the whole application does not pay for the runtime, no single
|
|
81
|
+
// call does either: the transform skips the compact attempt. A file
|
|
82
|
+
// the scan did not see (outside the root) decides alone, by the
|
|
83
|
+
// per-module rule, which never makes a bundle bigger either.
|
|
84
|
+
session.sharedDecision = pays ? 'smaller' : false
|
|
85
|
+
if (calls > 0) session.logger?.info?.(`[unplugin-ata] ${calls} validator(s) can share one error runtime, saving ${saving} bytes gzipped against its cost: ${pays ? 'shared' : 'kept self-contained'}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
28
88
|
},
|
|
29
89
|
|
|
30
90
|
async watchChange(id) {
|
|
@@ -55,7 +115,9 @@ export const unpluginFactory = (userOptions = {}) => {
|
|
|
55
115
|
}
|
|
56
116
|
return null
|
|
57
117
|
}
|
|
58
|
-
const
|
|
118
|
+
const file = id.split('?')[0]
|
|
119
|
+
const scanned = session.sharedDecision !== undefined && session.scanned && session.scanned.has(path.resolve(file))
|
|
120
|
+
const out = compileAway(code, file, cachedApi(session, api), { sharedErrors: scanned ? session.sharedDecision : userOptions.sharedErrors })
|
|
59
121
|
return out ? { code: out.code, map: out.map } : null
|
|
60
122
|
},
|
|
61
123
|
|