diff --git a/.changeset/perf-precompiled-options-schema.md b/.changeset/perf-precompiled-options-schema.md new file mode 100644 index 000000000..5f0a205b3 --- /dev/null +++ b/.changeset/perf-precompiled-options-schema.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Validate options with a precompiled schema to cut ~155ms from startup. diff --git a/.prettierignore b/.prettierignore index b1dc5fc37..a5ea852fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ /test/fixtures /test/outputs CHANGELOG.md +/src/options.check.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 6760ed0ab..bc11fa071 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import configs from "eslint-config-webpack/configs.js"; export default defineConfig([ - globalIgnores(["client/**/*", "examples/**/*"]), + globalIgnores(["client/**/*", "examples/**/*", "src/options.check.js"]), { extends: [configs["recommended-dirty"]], ignores: ["client-src/**/*"], diff --git a/package-lock.json b/package-lock.json index cbc4b1f76..ab1d2d87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webpack-dev-middleware", - "version": "8.2.0", + "version": "8.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webpack-dev-middleware", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT", "dependencies": { "ansi-html-community": "^0.0.8", @@ -29,6 +29,7 @@ "@types/mime-types": "^3.0.1", "@types/node": "^26.4.1", "acorn": "^8.18.0", + "ajv": "^8.20.0", "babel-jest": "^30.1.2", "babel-loader": "^10.1.1", "babel-plugin-istanbul": "^8.0.0", @@ -3042,6 +3043,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3059,6 +3077,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3154,47 +3179,6 @@ "fast-uri": "^4.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -6572,16 +6556,15 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -6606,28 +6589,10 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", - "dev": true, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -6640,23 +6605,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -10010,6 +9958,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10040,6 +10005,13 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10732,47 +10704,6 @@ "rfdc": "^1.2.0" } }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -10999,6 +10930,40 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/file-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/file-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/file-loader/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -13776,10 +13741,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -17312,22 +17276,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/schema-utils/node_modules/ajv-formats": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", @@ -17357,28 +17305,6 @@ "ajv": "^8.8.2" } }, - "node_modules/schema-utils/node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", diff --git a/package.json b/package.json index ca4e7e3f8..e1a9dd582 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "lint": "npm-run-all -l -p \"lint:**\"", "fix:js": "npm run lint:code -- --fix", "fix:prettier": "npm run lint:prettier -- --write", - "fix": "npm-run-all -l fix:js fix:prettier", + "fix": "npm-run-all -l fix:js fix:schema-check fix:prettier", "clean": "del-cli client dist types", "prebuild": "npm run clean", "build:types": "tsc && prettier \"types/**/*.ts\" --write", @@ -58,7 +58,9 @@ "prepare": "husky && npm run build", "version": "changeset version", "release": "npm run build && changeset publish", - "coverage:client": "node test/helpers/merge-coverage.js" + "coverage:client": "node test/helpers/merge-coverage.js", + "lint:schema-check": "node ./scripts/generate-schema-check.mjs --check", + "fix:schema-check": "node ./scripts/generate-schema-check.mjs" }, "dependencies": { "ansi-html-community": "^0.0.8", @@ -81,6 +83,7 @@ "@types/mime-types": "^3.0.1", "@types/node": "^26.4.1", "acorn": "^8.18.0", + "ajv": "^8.20.0", "babel-jest": "^30.1.2", "babel-loader": "^10.1.1", "babel-plugin-istanbul": "^8.0.0", diff --git a/scripts/generate-schema-check.mjs b/scripts/generate-schema-check.mjs new file mode 100644 index 000000000..013e9f52c --- /dev/null +++ b/scripts/generate-schema-check.mjs @@ -0,0 +1,170 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv, { _ } from "ajv"; +import standaloneCode from "ajv/dist/standalone/index.js"; + +// Precompile `src/options.json` into a standalone validator, `src/options.check.js`. +// +// Validating the options compiles the schema with ajv on the first call, which +// cost ~160ms of the first `middleware()` — a one-time startup price paid on +// every dev server start. The generated validator answers the same question +// with no compile step, so the happy path never loads ajv at all; the real +// validator is only reached when the options are invalid, to build the +// readable error message (the same trick webpack uses for its own schema). +// +// Run `npm run fix:schema-check` to regenerate, `npm run lint:schema-check` to +// verify the committed output is current. + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCHEMA_PATH = path.join(ROOT, "src", "options.json"); +const OUTPUT_PATH = path.join(ROOT, "src", "options.check.js"); + +const BANNER = `// This file was automatically generated. +// DO NOT MODIFY BY HAND. Run \`npm run fix:schema-check\` to update. +/* eslint-disable */ +// @ts-nocheck +`; + +/** + * The constructors `"instanceof"` may name. ajv-keywords implements the keyword + * with a closure, which cannot be serialized into standalone code, so the + * keyword is re-implemented here as something ajv can emit inline. + */ +const CONSTRUCTORS = { + Buffer: _`Buffer`, + Function: _`Function`, +}; + +/** @typedef {Record} SchemaNode */ + +/** + * Walk every schema node, depth first. + * @param {unknown} node current node + * @param {(node: SchemaNode, pointer: string) => void} visit called for each object node + * @param {string} pointer JSON pointer to `node` + * @returns {void} + */ +function walkSchema(node, visit, pointer = "#") { + if (!node || typeof node !== "object") { + return; + } + + if (!Array.isArray(node)) { + visit(/** @type {SchemaNode} */ (node), pointer); + } + + for (const [key, value] of Object.entries(node)) { + walkSchema(value, visit, `${pointer}/${key}`); + } +} + +/** + * Reject schema constructs the generated validator would silently mistranslate. + * @param {SchemaNode} schema the options schema + * @returns {void} + */ +function assertSupportedSchema(schema) { + /** + * @param {SchemaNode} node the schema node to check + * @param {string} pointer JSON pointer to `node` + * @returns {void} + */ + const assertNode = (node, pointer) => { + // `unicode: false` below makes ajv measure string length in UTF-16 code + // units rather than code points. The two agree only at a bound of 1, where + // both mean "not empty". + if (node.minLength !== undefined && node.minLength !== 1) { + throw new Error( + `"minLength" must be 1, but is ${node.minLength} at ${pointer}.`, + ); + } + + if (node.maxLength !== undefined) { + throw new Error(`"maxLength" is not supported, found at ${pointer}.`); + } + + if ( + typeof node.instanceof === "string" && + !Object.hasOwn(CONSTRUCTORS, node.instanceof) + ) { + throw new Error( + `"instanceof": ${JSON.stringify(node.instanceof)} at ${pointer} is not supported. Add it to CONSTRUCTORS.`, + ); + } + }; + + walkSchema(schema, assertNode); +} + +/** + * @param {SchemaNode} schema the options schema + * @returns {string} source of the standalone validator + */ +function generate(schema) { + assertSupportedSchema(schema); + + const ajv = new Ajv({ + /* eslint-disable no-console -- a generator reports to the terminal */ + logger: { + log: console.log, + /** + * `unicode` is deprecated but still honoured, and `assertSupportedSchema` + * has already established that dropping it changes nothing here. + * @param {...unknown} args ajv's warning arguments + * @returns {void} + */ + warn: (...args) => { + if (!String(args[0]).includes("option unicode")) { + console.warn(...args); + } + }, + error: console.error, + }, + /* eslint-enable no-console */ + strict: false, + // The validator only reports whether the options are valid; `schema-utils` + // produces the messages, so collecting every error here would be wasted work. + allErrors: false, + verbose: false, + unicode: false, + code: { source: true, esm: false }, + }); + + ajv.addKeyword({ + keyword: "instanceof", + schemaType: "string", + /** + * @param {import("ajv").KeywordCxt} cxt keyword context + * @returns {void} + */ + code(cxt) { + cxt.fail( + _`!(${cxt.data} instanceof ${CONSTRUCTORS[/** @type {keyof typeof CONSTRUCTORS} */ (cxt.schema)]})`, + ); + }, + }); + + // Documentation-only keywords carried by the schema for the CLI and the docs. + for (const keyword of ["cli", "link"]) { + ajv.addKeyword({ keyword, schemaType: ["string", "object", "boolean"] }); + } + + return BANNER + standaloneCode.default(ajv, ajv.compile(schema)); +} + +const source = generate(JSON.parse(await readFile(SCHEMA_PATH, "utf8"))); + +if (process.argv.includes("--check")) { + const current = await readFile(OUTPUT_PATH, "utf8").catch(() => undefined); + + if (current !== source) { + // eslint-disable-next-line no-console + console.error( + `${path.relative(ROOT, OUTPUT_PATH)} is out of date — run \`npm run fix:schema-check\`.`, + ); + process.exitCode = 1; + } +} else { + await writeFile(OUTPUT_PATH, source); +} diff --git a/src/index.js b/src/index.js index 920e346b8..eab161152 100644 --- a/src/index.js +++ b/src/index.js @@ -195,30 +195,47 @@ function isMultipleCompiler(compiler) { } /** + * Throw unless the options match the schema. * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @param {Compiler | MultiCompiler} compiler compiler * @param {Options} options options + * @returns {void} */ const internalValidate = (compiler, options) => { - const schema = require("./options.json"); - const firstCompiler = /** @type {Compiler & { validate: EXPECTED_ANY }} */ ( isMultipleCompiler(compiler) ? compiler.compilers[0] : compiler ); - if (typeof firstCompiler.validate === "function") { - firstCompiler.validate(schema, options, { - name: "Dev Middleware", - baseDataPath: "options", - }); + // `compiler.hooks.validate` and `compiler.validate`'s lazy-schema and + // precompiled-check parameters landed together in webpack 5.106, so the hook + // doubles as the feature probe for them. + if (firstCompiler.hooks.validate) { + firstCompiler.validate( + () => require("./options.json"), + options, + { name: "Dev Middleware", baseDataPath: "options" }, + /** + * @param {Options} value options to check + * @returns {boolean} whether they match the schema + */ + (value) => require("./options.check")(value), + ); + return; + } + + // TODO in the next major release bump minimum supported webpack version and + // remove this fallback in favor of `compiler.validate` (above). + // The precompiled validator answers the common case in ~2ms, against the + // ~160ms `schema-utils` spends compiling the schema on its first call, so + // `./options.json` stays unread until something is actually wrong. + if (require("./options.check")(options)) { return; } - // TODO in the next major release bump minimum supported webpack version and remove it in favor of `compiler.validate` (above) const { validate } = require("schema-utils"); - validate(/** @type {Schema} */ (schema), options, { + validate(/** @type {Schema} */ (require("./options.json")), options, { name: "Dev Middleware", baseDataPath: "options", }); diff --git a/src/options.check.js b/src/options.check.js new file mode 100644 index 000000000..c1cd0c6d1 --- /dev/null +++ b/src/options.check.js @@ -0,0 +1,5 @@ +// This file was automatically generated. +// DO NOT MODIFY BY HAND. Run `npm run fix:schema-check` to update. +/* eslint-disable */ +// @ts-nocheck +"use strict";module.exports = validate10;module.exports.default = validate10;const schema11 = {"type":"object","properties":{"mimeTypes":{"description":"Allows a user to register custom mime types or extension mappings.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypes","type":"object"},"mimeTypeDefault":{"description":"Allows a user to register a default mime type when we can't determine the content type.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypedefault","type":"string"},"writeToDisk":{"description":"Allows to write generated files on disk.","link":"https://github.com/webpack/webpack-dev-middleware#writetodisk","anyOf":[{"type":"boolean"},{"instanceof":"Function"}]},"methods":{"description":"Allows to pass the list of HTTP request methods accepted by the middleware.","link":"https://github.com/webpack/webpack-dev-middleware#methods","type":"array","items":{"type":"string","minLength":1}},"headers":{"anyOf":[{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}}},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to pass custom HTTP headers on each request","link":"https://github.com/webpack/webpack-dev-middleware#headers"},"publicPath":{"description":"The `publicPath` specifies the public URL address of the output files when referenced in a browser.","link":"https://github.com/webpack/webpack-dev-middleware#publicpath","anyOf":[{"enum":["auto"]},{"type":"string"},{"instanceof":"Function"}]},"stats":{"description":"Stats options object or preset name.","link":"https://github.com/webpack/webpack-dev-middleware#stats","anyOf":[{"enum":["none","summary","errors-only","errors-warnings","minimal","normal","detailed","verbose"]},{"type":"boolean"},{"type":"object","additionalProperties":true}]},"serverSideRender":{"description":"Instructs the module to enable or disable the server-side rendering mode.","link":"https://github.com/webpack/webpack-dev-middleware#serversiderender","type":"boolean"},"outputFileSystem":{"description":"Set the default file system which will be used by webpack as primary destination of generated files.","link":"https://github.com/webpack/webpack-dev-middleware#outputfilesystem","type":"object"},"index":{"description":"Allows to serve an index of the directory.","link":"https://github.com/webpack/webpack-dev-middleware#index","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"modifyResponseData":{"description":"Allows to set up a callback to change the response data.","link":"https://github.com/webpack/webpack-dev-middleware#modifyresponsedata","instanceof":"Function"},"etag":{"description":"Enable or disable etag generation.","link":"https://github.com/webpack/webpack-dev-middleware#etag","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","link":"https://github.com/webpack/webpack-dev-middleware#lastmodified","type":"boolean"},"cacheControl":{"description":"Enable or disable setting `Cache-Control` response header.","link":"https://github.com/webpack/webpack-dev-middleware#cachecontrol","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"cacheImmutable":{"description":"Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).","link":"https://github.com/webpack/webpack-dev-middleware#cacheimmutable","type":"boolean"},"forwardError":{"description":"Enable or disable forwarding errors to next middleware.","link":"https://github.com/webpack/webpack-dev-middleware#forwarderrors","type":"boolean"},"hot":{"description":"Enable hot module replacement via a Server-Sent Events endpoint.","link":"https://github.com/webpack/webpack-dev-middleware#hot","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"path":{"description":"The path the SSE endpoint is served at. Must start with a slash and carry no query string or fragment.","type":"string","pattern":"^/[^?#]*$"},"heartbeat":{"description":"Heartbeat interval (in milliseconds) used to keep the SSE connection alive.","type":"number","minimum":1},"progress":{"description":"Publish compilation progress events to the clients.","type":"boolean"},"statsOptions":{"description":"Deprecated, do not use, will be removed in the next major release. Use the `stats` option instead, which decides whether a payload carries errors and warnings.","type":"object","additionalProperties":true}}}]}},"additionalProperties":false};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^/[^?#]*$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){const _errs1 = errors;for(const key0 in data){if(!(func2.call(schema11.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs1 === errors){if(data.mimeTypes !== undefined){let data0 = data.mimeTypes;const _errs2 = errors;if(!(data0 && typeof data0 == "object" && !Array.isArray(data0))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs5 = errors;if(typeof data.mimeTypeDefault !== "string"){validate10.errors = [{instancePath:instancePath+"/mimeTypeDefault",schemaPath:"#/properties/mimeTypeDefault/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data2 = data.writeToDisk;const _errs8 = errors;const _errs9 = errors;let valid1 = false;const _errs10 = errors;if(typeof data2 !== "boolean"){const err0 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs10 === errors;valid1 = valid1 || _valid0;if(!valid1){const _errs12 = errors;if(!(data2 instanceof Function)){const err1 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs12 === errors;valid1 = valid1 || _valid0;}if(!valid1){const err2 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs9;if(vErrors !== null){if(_errs9){vErrors.length = _errs9;}else {vErrors = null;}}}var valid0 = _errs8 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data3 = data.methods;const _errs14 = errors;if(errors === _errs14){if(Array.isArray(data3)){var valid2 = true;const len0 = data3.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}else {const err38 = {instancePath:instancePath+"/hot/heartbeat",schemaPath:"#/properties/hot/anyOf/1/properties/heartbeat/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}var valid12 = _errs101 === errors;}else {var valid12 = true;}if(valid12){if(data22.progress !== undefined){const _errs103 = errors;if(typeof data22.progress !== "boolean"){const err39 = {instancePath:instancePath+"/hot/progress",schemaPath:"#/properties/hot/anyOf/1/properties/progress/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}var valid12 = _errs103 === errors;}else {var valid12 = true;}if(valid12){if(data22.statsOptions !== undefined){let data26 = data22.statsOptions;const _errs105 = errors;if(errors === _errs105){if(data26 && typeof data26 == "object" && !Array.isArray(data26)){}else {const err40 = {instancePath:instancePath+"/hot/statsOptions",schemaPath:"#/properties/hot/anyOf/1/properties/statsOptions/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}var valid12 = _errs105 === errors;}else {var valid12 = true;}}}}}}else {const err41 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var _valid6 = _errs96 === errors;valid11 = valid11 || _valid6;}if(!valid11){const err42 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs93;if(vErrors !== null){if(_errs93){vErrors.length = _errs93;}else {vErrors = null;}}}var valid0 = _errs92 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/test/schema-check.test.js b/test/schema-check.test.js new file mode 100644 index 000000000..f49690196 --- /dev/null +++ b/test/schema-check.test.js @@ -0,0 +1,93 @@ +import middleware from "../src"; +import validateOptions from "../src/options.check"; + +import webpackConfig from "./fixtures/webpack.config"; +import getCompiler from "./helpers/getCompiler"; + +// Suppress unnecessary stats output +jest.spyOn(globalThis.console, "log").mockImplementation(); + +// `validation-options.test.js` drives the option corpus through `middleware()`, +// which catches a validator that wrongly *accepts* invalid options. It cannot +// see one that wrongly *rejects* valid options: validation falls back to the +// real schema, which accepts them, so every test still passes while the +// startup cost this validator exists to avoid comes back. These assertions are +// what notices that. +describe("precompiled options validator", () => { + const valid = [ + ["empty options", {}], + ["mimeTypes", { mimeTypes: { phtml: ["text/html"] } }], + ["writeToDisk as a boolean", { writeToDisk: true }], + ["writeToDisk as a function", { writeToDisk: () => true }], + ["methods", { methods: ["GET", "HEAD"] }], + ["headers as an object", { headers: { "X-Custom": "value" } }], + ["headers as an array", { headers: [{ key: "X-Custom", value: "v" }] }], + ["headers as a function", { headers: () => ({}) }], + ["publicPath as a string", { publicPath: "/assets/" }], + ["publicPath as auto", { publicPath: "auto" }], + ["stats as a boolean", { stats: false }], + ["stats as a string", { stats: "minimal" }], + ["serverSideRender", { serverSideRender: true }], + ["index as a string", { index: "index.html" }], + ["etag", { etag: "weak" }], + ["lastModified", { lastModified: true }], + ["cacheControl as a number", { cacheControl: 1000 }], + ["modifyResponseData", { modifyResponseData: () => ({}) }], + ]; + + for (const [name, options] of valid) { + it(`should accept ${name} without falling back`, () => { + expect(validateOptions(options)).toBe(true); + }); + } + + const invalid = [ + ["an unknown property", { unknownOption: true }], + ["a wrongly typed option", { writeToDisk: {} }], + ["a value outside an enum", { etag: "whoops!" }], + ["a bad publicPath", { publicPath: 1 }], + ["a non-function where a function is required", { modifyResponseData: 1 }], + ["a bad mimeTypes", { mimeTypes: "foo" }], + ]; + + for (const [name, options] of invalid) { + it(`should reject ${name}`, () => { + expect(validateOptions(options)).toBe(false); + }); + } +}); + +// webpack < 5.106 has neither `compiler.hooks.validate` nor `compiler.validate`'s +// precompiled-check parameter, so the middleware validates directly instead. +// CI installs a newer webpack, so nothing else reaches that fallback. +describe("validation without the validate hook", () => { + const withoutValidateHook = (compiler) => { + const hooks = { ...compiler.hooks }; + + delete hooks.validate; + Object.defineProperty(compiler, "hooks", { value: hooks }); + + return compiler; + }; + + it("should accept valid options", (done) => { + const compiler = withoutValidateHook(getCompiler(webpackConfig)); + const validateSpy = jest.spyOn(compiler, "validate"); + const instance = middleware(compiler, {}); + + // The fallback validated these, not the compiler. + expect(validateSpy).not.toHaveBeenCalled(); + + instance.waitUntilValid(() => { + instance.close(done); + }); + }); + + it("should reject invalid options", () => { + const compiler = withoutValidateHook(getCompiler(webpackConfig)); + + expect(() => middleware(compiler, { unknownOption: true })).toThrow( + /Dev Middleware/, + ); + }); +}); diff --git a/types/options.check.d.ts b/types/options.check.d.ts new file mode 100644 index 000000000..5ac7f59b6 --- /dev/null +++ b/types/options.check.d.ts @@ -0,0 +1,16 @@ +export = validate10; +declare function validate10( + data: any, + { + instancePath, + parentData, + parentDataProperty, + rootData, + }?: { + instancePath?: string | undefined; + rootData?: any; + }, +): boolean; +declare namespace validate10 { + export { validate10 as default }; +}