diff --git a/.changeset/precompile-minify.md b/.changeset/precompile-minify.md new file mode 100644 index 0000000..0b5d52f --- /dev/null +++ b/.changeset/precompile-minify.md @@ -0,0 +1,5 @@ +--- +"schema-utils": minor +--- + +`schema-utils precompile --minify` (and the `minify` option of `precompile`) writes a smaller check that loads faster, minified with webpack's minifier (webpack 5.112.0 or later). diff --git a/README.md b/README.md index dc85569..588f5f4 100644 --- a/README.md +++ b/README.md @@ -290,8 +290,13 @@ out of date with its schema. The generated module may need `schema-utils` as a d package. It is an ES module when the nearest `package.json` has `"type": "module"`, the way Node.js reads a `.js` file there, and CommonJS otherwise. +With `--minify`, the check drops the error objects it never reports, has its helpers written into +it instead of loaded from `schema-utils`, and is minified with webpack's own minifier, which needs +webpack 5.112.0 or later. For webpack's own options that is less than half the size and loads in +13ms instead of 21ms. `--check` has to be given the same `--minify` to compare against the right code. + A relative `$ref` to another file resolves against the schema. To compile a schema from code, use -`precompile(schema, { loadSchema, esm })`, it returns the source of the module. +`precompile(schema, { loadSchema, esm, minify })`, it returns the source of the module. ### Allow to disable and enable validation (the `validate` function do nothing) diff --git a/declarations/precompile.d.ts b/declarations/precompile.d.ts index 0413c1f..6c54e46 100644 --- a/declarations/precompile.d.ts +++ b/declarations/precompile.d.ts @@ -8,6 +8,10 @@ export type PrecompileOptions = { * generate an ES module instead of a CommonJS one */ esm?: boolean | undefined; + /** + * drop the error objects a check never reports and minify the code with webpack's minifier, which needs webpack 5.112.0 or later installed + */ + minify?: boolean | undefined; }; export type Ajv = import("ajv").default; export type AnySchemaObject = import("ajv").AnySchemaObject; @@ -18,6 +22,7 @@ export type EXPECTED_ANY = any; * @typedef {object} PrecompileOptions * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to * @property {boolean=} esm generate an ES module instead of a CommonJS one + * @property {boolean=} minify drop the error objects a check never reports and minify the code with webpack's minifier, which needs webpack 5.112.0 or later installed */ /** * Compiles a schema into the source of a module that exports a check for it, CommonJS unless diff --git a/package-lock.json b/package-lock.json index adf69ef..92fd1f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "schema-utils", - "version": "4.5.0", + "version": "4.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "schema-utils", - "version": "4.5.0", + "version": "4.7.0", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -35,7 +35,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.9.6", "typescript": "^6.0.3", - "webpack": "^5.110.3" + "webpack": "^5.112.0" }, "engines": { "node": ">= 10.13.0" @@ -43,6 +43,14 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.112.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } } }, "node_modules/@babel/cli": { @@ -3204,6 +3212,8 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5642,6 +5652,8 @@ }, "node_modules/buffer-from": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT" }, @@ -6343,7 +6355,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.5", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "dev": true, "license": "MIT", "dependencies": { @@ -11772,13 +11786,15 @@ } }, "node_modules/minimizer-webpack-plugin": { - "version": "5.10.0", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.13.1.tgz", + "integrity": "sha512-1mhAfvachWYYOm8tpmany5NLuvP+F2wxZWBSX8DC0uFS2wJpM9q3rWgN9wBooAH9d5V12wX0BjyJe4fMjHMarQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "jest-worker": "^27.4.5", - "schema-utils": "^4.3.3", + "schema-utils": "^4.6.0", "terser": "^5.51.0" }, "engines": { @@ -11917,11 +11933,6 @@ "dev": true, "license": "MIT" }, - "node_modules/neo-async": { - "version": "2.6.2", - "dev": true, - "license": "MIT" - }, "node_modules/nice-try": { "version": "1.0.5", "dev": true, @@ -13180,15 +13191,20 @@ "license": "MIT" }, "node_modules/schema-utils": { - "version": "4.3.3", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.7.0.tgz", + "integrity": "sha512-kCw050Ufqn0e2pBC+70gB+kU7ZwbbatRETEoedSmdWwmJkCC28SlwinDcARzjWPhq9haoPso+/kzMAFGjd4fXw==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" }, + "bin": { + "schema-utils": "bin/schema-utils.js" + }, "engines": { "node": ">= 10.13.0" }, @@ -13197,22 +13213,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/semver": { "version": "6.3.1", "dev": true, @@ -13437,6 +13437,8 @@ }, "node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13445,6 +13447,8 @@ }, "node_modules/source-map-support": { "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { @@ -13826,6 +13830,8 @@ }, "node_modules/terser": { "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -13843,6 +13849,8 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT" }, @@ -14565,7 +14573,9 @@ } }, "node_modules/watchpack": { - "version": "2.5.2", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.6.3.tgz", + "integrity": "sha512-s3NZy0QpTY9qGqzc0wtz4qI28z3+5qKbbAec4MV4G9N3CfgZ9wgvQRr0cl2jh/tqkWG1NWHkmVMHZpCamev5Wg==", "dev": true, "license": "MIT", "dependencies": { @@ -14576,7 +14586,9 @@ } }, "node_modules/webpack": { - "version": "5.110.3", + "version": "5.112.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.0.tgz", + "integrity": "sha512-NIBpsRuCll036haDDt/CfcTDfsWNR9Z782nMRMI9D377BsGcYzZDTNU5TyzhCJzPIcihAxMBaMAhq2WSaDrH8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -14585,20 +14597,18 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.24.4", + "enhanced-resolve": "^5.26.0", "es-module-lexer": "^2.1.0", "events": "^3.2.0", "graceful-fs": "^4.2.11", "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.7.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", + "minimizer-webpack-plugin": "^5.13.0", + "schema-utils": "^4.5.0", "tapable": "^2.3.0", - "watchpack": "^2.5.2", - "webpack-sources": "^3.5.1" + "watchpack": "^2.6.1", + "webpack-sources": "^3.6.0" }, "bin": { "webpack": "bin/webpack.js" @@ -14617,7 +14627,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.5.1", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 50481b7..73dd991 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,15 @@ "npm-run-all": "^4.1.5", "prettier": "^3.9.6", "typescript": "^6.0.3", - "webpack": "^5.110.3" + "webpack": "^5.112.0" + }, + "peerDependencies": { + "webpack": "^5.112.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } }, "engines": { "node": ">= 10.13.0" diff --git a/src/cli.js b/src/cli.js index 77c6075..6d6f57e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,4 +1,4 @@ -const USAGE = `Usage: schema-utils precompile [--check] ... +const USAGE = `Usage: schema-utils precompile [--check] [--minify] ... Compiles every schema into a check next to it, \`options.json\` into \`options.check.js\`, for the fourth argument of \`validate\`: @@ -13,7 +13,9 @@ the way Node.js reads a .js file there, and a CommonJS module otherwise: validate(schema, options, configuration, check); Options: - --check write nothing, fail when a check is missing or out of date`; + --check write nothing, fail when a check is missing or out of date + --minify drop the error objects a check never reports and minify it with webpack's + minifier, which needs webpack 5.112.0 or later, for a smaller module that loads faster`; /** * @param {string} schemaPath path of the schema @@ -96,7 +98,8 @@ async function cli(args, io) { } const check = rest.includes("--check"); - const files = rest.filter((arg) => arg !== "--check"); + const minify = rest.includes("--minify"); + const files = rest.filter((arg) => arg !== "--check" && arg !== "--minify"); const unknown = files.find((arg) => arg.startsWith("-")); if (unknown) { @@ -133,7 +136,7 @@ async function cli(args, io) { schema.$id = pathToFileURL(schemaPath).href; } - code = await precompile(schema, { esm: isModule(checkPath) }); + code = await precompile(schema, { esm: isModule(checkPath), minify }); } catch (error) { io.error( `Cannot precompile "${file}": ${/** @type {Error} */ (error).message}`, diff --git a/src/precompile.js b/src/precompile.js index 3a17d24..73c9947 100644 --- a/src/precompile.js +++ b/src/precompile.js @@ -23,9 +23,10 @@ const RUNTIME_MODULE = `${RUNTIME}.js`; /** * @param {Ajv} ajv ajv + * @param {boolean} hoist whether to keep the values a keyword needs in module scope * @returns {void} */ -function addKeywords(ajv) { +function addKeywords(ajv, hoist) { const { Name, _ } = require("ajv"); // The same constructors `ajv-keywords` accepts @@ -73,11 +74,20 @@ function addKeywords(ajv) { * @param {KeywordCxt} cxt context */ code(cxt) { - const { data, schema } = cxt; + const { data, gen, schema } = cxt; + + // Keep in sync with `keywords/absolutePath.js`. Minified, the regex is one constant for the + // whole module, like any other value ajv keeps in scope, instead of a new one on every call. + const absolutePathRegExp = hoist + ? gen.scopeValue("pattern", { + key: "absolutePath", + ref: /^(?:file:(?=\/))?(?:[A-Za-z]:(\\|\/)|\\\\|\/)/i, + code: _`/^(?:file:(?=\\/))?(?:[A-Za-z]:(\\\\|\\/)|\\\\\\\\|\\/)/i`, + }) + : _`/^(?:file:(?=\\/))?(?:[A-Za-z]:(\\\\|\\/)|\\\\\\\\|\\/)/i`; - // Keep in sync with `keywords/absolutePath.js` cxt.fail( - _`${data}.includes("!") || /^(?:file:(?=\\/))?(?:[A-Za-z]:(\\\\|\\/)|\\\\\\\\|\\/)/i.test(${data}) !== ${schema}`, + _`${data}.includes("!") || ${absolutePathRegExp}.test(${data}) !== ${schema}`, ); }, }); @@ -123,9 +133,10 @@ function addKeywords(ajv) { /** * @param {string} code generated code * @param {boolean} esm whether the code is an ES module + * @param {boolean} inline whether to write the helper functions into the code * @returns {string} code loading the runtime helpers from `schema-utils` */ -function useRuntime(code, esm) { +function useRuntime(code, esm, inline) { return code.replace( /require\("ajv\/dist\/runtime\/(\w+)"\)\.default/g, (match, name) => { @@ -133,6 +144,12 @@ function useRuntime(code, esm) { throw new Error(`Unsupported "ajv" runtime helper "${name}"`); } + // WHY: loading a helper through `schema-utils` costs about 1ms, more than the check itself + // takes to run for most options - both functions are small and self-contained, `uri` is not + if (inline && name !== "uri") { + return `(${require(`ajv/dist/runtime/${name}`).default.toString()})`; + } + // ajv keeps `require` for its helpers in an ES module too return esm ? `${RUNTIME_BINDING}.${name}` @@ -232,10 +249,54 @@ async function loadFileSchema(uri) { return JSON.parse(readFileSync(fileURLToPath(uri), "utf8")); } +/** + * WHY: a check only says whether a value is valid, so the error objects ajv still builds are dead + * weight, and the code is loaded on every start of the tool that ships it - for webpack's own options + * that halves the size of the module and the time it takes to load. + * @param {string} code generated code + * @param {boolean} esm whether the code is an ES module + * @returns {Promise} code without error objects, minified + */ +async function minify(code, esm) { + /** @type {{ minify: (code: string, options: Record) => Promise<{ code?: string }> }} */ + let minifier; + + // webpack's own minifier, which writes what terser writes without loading it - the packages + // shipping a check already have webpack. Required by name so neither TypeScript nor a bundler + // reaches for it. + const WEBPACK = "webpack"; + + try { + minifier = await require(WEBPACK).javascript.syntax.printer.load(); + } catch (err) { + throw new Error( + "Install webpack 5.112.0 or later to minify a precompiled check", + { cause: err }, + ); + } + + // Only an error object ajv assigns, never a string or regex of the schema that merely looks like + // one: `= {instancePath:instancePath+…,schemaPath:"…",keyword:"…",` loses all but its `params` + const stripped = code.replace( + /(=\s*\[?)\{instancePath(?::instancePath\b[^{}]*?)?,schemaPath:"(?:[^"\\]|\\.)*",keyword:"\w+",/g, + "$1{", + ); + const result = await minifier.minify(stripped, { + compress: { passes: 3 }, + mangle: true, + ecma: 2015, + module: esm, + toplevel: true, + }); + + return /** @type {string} */ (result.code); +} + /** * @typedef {object} PrecompileOptions * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to * @property {boolean=} esm generate an ES module instead of a CommonJS one + * @property {boolean=} minify drop the error objects a check never reports and minify the code with webpack's minifier, which needs webpack 5.112.0 or later installed */ /** @@ -276,7 +337,7 @@ async function precompile(schema, options = {}) { const addLimitKeyword = require("./keywords/limit").default; addLimitKeyword(ajv); - addKeywords(ajv); + addKeywords(ajv, Boolean(options.minify)); const keywords = new Set([ ...Object.keys(ajv.RULES.all), @@ -294,7 +355,11 @@ async function precompile(schema, options = {}) { const validate = await ajv.compileAsync(stripAnnotations(schema, keywords)); - let code = useRuntime(standaloneCode(ajv, validate), esm); + let code = useRuntime( + standaloneCode(ajv, validate), + esm, + Boolean(options.minify), + ); // The `$id` of a schema read from a file is its absolute URL, which ends up in the code, so the // URLs of the schemas are made relative to the schema for the generated file to be the same on @@ -322,6 +387,10 @@ async function precompile(schema, options = {}) { code = `import ${RUNTIME_BINDING} from ${JSON.stringify(RUNTIME_MODULE)};\n${code}`; } + if (options.minify) { + code = await minify(code, esm); + } + return `/* eslint-disable */ // @ts-nocheck /* diff --git a/test/helpers/precompiled.js b/test/helpers/precompiled.js index 46c58e4..76682ec 100644 --- a/test/helpers/precompiled.js +++ b/test/helpers/precompiled.js @@ -12,7 +12,7 @@ const cache = new WeakMap(); * @param {string} code source of the module * @returns {(value: EXPECTED_ANY) => boolean} check */ -function load(code) { +export function load(code) { const module = { exports: {} }; /** * @param {string} request request diff --git a/test/precompile.test.js b/test/precompile.test.js index 1abadf6..8e6cb64 100644 --- a/test/precompile.test.js +++ b/test/precompile.test.js @@ -7,7 +7,7 @@ import { pathToFileURL } from "url"; import { precompile, validate } from "../src"; import cli from "../src/cli"; -import { getCheck } from "./helpers/precompiled"; +import { getCheck, load } from "./helpers/precompiled"; const runtime = require("../src/runtime"); @@ -151,6 +151,81 @@ describe("precompile", () => { expect(code).toMatch(/"description"/); }); + describe("minified", () => { + const schema = { + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + mode: { enum: [{ a: 1 }] }, + context: { type: "string", absolutePath: true }, + output: { type: "string", absolutePath: true }, + }, + additionalProperties: false, + }; + + it("should drop the error objects and inline the helpers", async () => { + const code = await precompile(schema, { minify: true }); + + expect(code.length).toBeLessThan((await precompile(schema)).length); + // Only the parameter of the check is still named `instancePath` + expect(code).not.toMatch(/schemaPath|keyword:/); + // `ucs2length` and `equal` are written into the check + expect(code).not.toMatch(/require\(/); + // One regex for every `absolutePath` + expect(code.match(/\[A-Za-z\]:/g)).toHaveLength(1); + expect(code).toMatch(/^\/\* eslint-disable \*\/\n\/\/ @ts-nocheck\n/); + }); + + it("should check like the full validation", async () => { + const check = load(await precompile(schema, { minify: true })); + + expect(check({ name: "ab", mode: { a: 1 }, context: "/abs" })).toBe(true); + // One astral character is one character, as for the full validation + expect(check({ name: "😀" })).toBe(false); + expect(check({ name: "😀😀" })).toBe(true); + expect(check({ mode: { a: 2 } })).toBe(false); + expect(check({ context: "relative" })).toBe(false); + expect(check({ output: "/abs!loader" })).toBe(false); + expect(check({ other: true })).toBe(false); + }); + + it("should keep a schema pattern that looks like an error object", async () => { + const check = load( + await precompile( + { type: "string", pattern: "\\{instancePathx,keyword:y," }, + { minify: true }, + ), + ); + + expect(check("{instancePathx,keyword:y,")).toBe(true); + expect(check("other")).toBe(false); + }); + + it("should keep an ES module", async () => { + const code = await precompile( + { type: "object", properties: { date: { format: "date" } } }, + { esm: true, minify: true }, + ); + + expect(code).toMatch(/export\{\w+ as default\}|export default/); + expect(code).toMatch(/import \w+ from"schema-utils\/dist\/runtime\.js"/); + expect(code).not.toMatch(/require\(|module\.exports/); + }); + + it("should ask for webpack when it has no minifier", async () => { + // webpack before 5.112.0 + jest.doMock("webpack", () => ({})); + + try { + await expect(precompile(schema, { minify: true })).rejects.toThrow( + "Install webpack 5.112.0 or later to minify a precompiled check", + ); + } finally { + jest.dontMock("webpack"); + } + }); + }); + it("should load the runtime helpers from schema-utils", async () => { const code = await precompile({ type: "object", @@ -471,6 +546,32 @@ describe("precompile", () => { expect(otherCode).not.toMatch(/export default/); }); + it("should minify the check with --minify", async () => { + const file = writeJson("options.json", { + type: "object", + properties: { name: { type: "string", minLength: 2 } }, + }); + const checkFile = path.join(dir, "options.check.js"); + + let result = await runCli(["precompile", "--minify", file]); + + expect(result.exitCode).toBe(0); + + const code = fs.readFileSync(checkFile, "utf8"); + + expect(code).not.toMatch(/require\(|schemaPath/); + + result = await runCli(["precompile", "--check", "--minify", file]); + + expect(result).toEqual({ exitCode: 0, log: [], error: [] }); + + // Checked against the unminified code, the minified one is out of date + result = await runCli(["precompile", "--check", file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/options\.check\.js" is out of date/); + }); + it("should write CommonJS when package.json cannot be read", async () => { fs.writeFileSync(path.join(dir, "package.json"), "{");