@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 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.6.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.40.0",
82
+ "ata-validator": "^1.51.0",
83
83
  "esbuild": "^0.28.0",
84
84
  "rolldown": "^1.2.8",
85
85
  "rollup": "^4.60.0",
@@ -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
- let ast
250
- try {
251
- ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: false })
252
- } catch {
253
- return null
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 { compileAway } from './compile-away.js'
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 out = compileAway(code, id.split('?')[0], api)
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