From 3d00638a8628af3b5b9534a82cc22dba7af5c2e2 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:44:33 +0000 Subject: [PATCH 1/5] perf: validate options with a precompiled schema Validating the options compiles the schema with ajv on the first call, which cost 159ms of the first `middleware()` against 0.08ms for each call after it. Every webpack-dev-server start pays it once. `src/options.check.js` is that schema precompiled by `ajv`'s standalone codegen. `internalValidate` asks it first and only falls through to `compiler.validate`/`schema-utils` when it rejects, so the happy path never loads ajv, `options.json` stays unread until something is actually wrong, and an invalid config still gets the same message. The generated validator agrees with `schema-utils` on all 76 cases of the `validation-options` corpus. Checking here rather than passing the validator to `compiler.validate` as its fourth argument keeps this working across the whole supported webpack range, not only versions carrying that parameter. Validation drops to 1.9ms, and a dev server's time-to-listening for a hello-world falls from 523 +/- 25ms to 438 +/- 17ms. --- .changeset/perf-precompiled-options-schema.md | 5 + .prettierignore | 1 + eslint.config.mjs | 2 +- package-lock.json | 272 +++++++----------- package.json | 7 +- scripts/generate-schema-check.mjs | 159 ++++++++++ src/index.js | 8 + src/options.check.js | 5 + test/schema-check.test.js | 51 ++++ types/options.check.d.ts | 16 ++ 10 files changed, 350 insertions(+), 176 deletions(-) create mode 100644 .changeset/perf-precompiled-options-schema.md create mode 100644 scripts/generate-schema-check.mjs create mode 100644 src/options.check.js create mode 100644 test/schema-check.test.js create mode 100644 types/options.check.d.ts 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..f1fde62ee --- /dev/null +++ b/scripts/generate-schema-check.mjs @@ -0,0 +1,159 @@ +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) { + walkSchema(schema, (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.`, + ); + } + }); +} + +/** + * @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. + 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..8f950cdc2 100644 --- a/src/index.js +++ b/src/index.js @@ -201,6 +201,14 @@ function isMultipleCompiler(compiler) { * @param {Options} options options */ const internalValidate = (compiler, options) => { + // The precompiled validator answers the common case without ajv, which + // would otherwise spend ~160ms compiling the schema on the first call. + // Rejected options fall through to the real validator for the message, so + // `./options.json` stays unread until something is actually wrong. + if (require("./options.check")(options)) { + return; + } + const schema = require("./options.json"); const firstCompiler = /** @type {Compiler & { validate: EXPECTED_ANY }} */ ( 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..02b141912 --- /dev/null +++ b/test/schema-check.test.js @@ -0,0 +1,51 @@ +import validateOptions from "../src/options.check"; + +// `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); + }); + } +}); 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 }; +} From ae0fcd2df9ee541ee0697a24cb39290d89b66eba Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:00:10 +0000 Subject: [PATCH 2/5] chore: document the generator's callbacks --- scripts/generate-schema-check.mjs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/generate-schema-check.mjs b/scripts/generate-schema-check.mjs index f1fde62ee..013e9f52c 100644 --- a/scripts/generate-schema-check.mjs +++ b/scripts/generate-schema-check.mjs @@ -65,7 +65,12 @@ function walkSchema(node, visit, pointer = "#") { * @returns {void} */ function assertSupportedSchema(schema) { - walkSchema(schema, (node, pointer) => { + /** + * @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". @@ -87,7 +92,9 @@ function assertSupportedSchema(schema) { `"instanceof": ${JSON.stringify(node.instanceof)} at ${pointer} is not supported. Add it to CONSTRUCTORS.`, ); } - }); + }; + + walkSchema(schema, assertNode); } /** @@ -101,8 +108,12 @@ function generate(schema) { /* 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. + /** + * `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); From 2e6082b965a83a35772acb0ca40bf8b7993f4f72 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:02:05 +0000 Subject: [PATCH 3/5] chore: complete internalValidate's JSDoc --- src/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.js b/src/index.js index 8f950cdc2..0fbc6a4e2 100644 --- a/src/index.js +++ b/src/index.js @@ -195,10 +195,12 @@ 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) => { // The precompiled validator answers the common case without ajv, which From 583e6d0d36e8990958fa66b81ff9d649b1c07b72 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:18:39 +0000 Subject: [PATCH 4/5] refactor: validate options through the compiler where webpack allows it `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. When it is there, the precompiled check is handed to the compiler the way webpack's own plugins hand it theirs, rather than being run before it. The direct path stays as the fallback, keeping the existing TODO to drop it once the minimum supported webpack carries the hook. --- src/index.js | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/index.js b/src/index.js index 0fbc6a4e2..eab161152 100644 --- a/src/index.js +++ b/src/index.js @@ -203,32 +203,39 @@ function isMultipleCompiler(compiler) { * @returns {void} */ const internalValidate = (compiler, options) => { - // The precompiled validator answers the common case without ajv, which - // would otherwise spend ~160ms compiling the schema on the first call. - // Rejected options fall through to the real validator for the message, so - // `./options.json` stays unread until something is actually wrong. - if (require("./options.check")(options)) { - return; - } - - 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", }); From 31d61d74d893e2ad16176ae1c30f6aef6efcc55e Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:57:28 +0000 Subject: [PATCH 5/5] test: cover validation without the validate hook codecov flagged the fallback as the uncovered part of the diff: CI installs webpack 5.110, where `compiler.hooks.validate` always exists, so nothing reached the path taken on webpack < 5.106. Replacing the frozen `compiler.hooks` with a copy that lacks `validate` exercises it, and the spy asserts the fallback validated rather than the compiler. Without these two tests, lines 232, 233, 236 and 238 are uncovered; with them, every statement in `internalValidate` is. --- test/schema-check.test.js | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/schema-check.test.js b/test/schema-check.test.js index 02b141912..f49690196 100644 --- a/test/schema-check.test.js +++ b/test/schema-check.test.js @@ -1,5 +1,12 @@ +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 @@ -49,3 +56,38 @@ describe("precompiled options validator", () => { }); } }); + +// 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/, + ); + }); +});