Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perf-precompiled-options-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-server": patch
---

Validate options with a precompiled schema to cut ~115ms from startup.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coverage
node_modules
CHANGELOG.md
examples/client/trusted-types-overlay/app.js
/lib/options.check.js
7 changes: 6 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import config from "eslint-config-webpack";
import configs from "eslint-config-webpack/configs.js";

export default defineConfig([
globalIgnores(["client/**/*", "dist/**/*", "examples/**/*"]),
globalIgnores([
"client/**/*",
"dist/**/*",
"examples/**/*",
"lib/options.check.js",
]),
{
extends: [config],
ignores: ["client-src/**/*", "!client-src/webpack.config.js"],
Expand Down
120 changes: 81 additions & 39 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import url from "node:url";
import fs from "graceful-fs";
import ipaddr from "ipaddr.js";
import { validate } from "schema-utils";
import schemaCheck from "./options.check.js";
import schema from "./options.json" with { type: "json" };

// Named `cjsRequire` (not `require`) so it doesn't shadow the implicit CommonJS
Expand Down Expand Up @@ -343,12 +344,9 @@ class Server {
constructor(options, compiler) {
options = options === undefined ? {} : options;

validate(/** @type {Schema} */ (schema), options, {
name: "Dev Server",
baseDataPath: "options",
});

if (compiler) {
Server.validateOptions(compiler, options);

this.compiler = compiler;

/**
Expand Down Expand Up @@ -578,6 +576,76 @@ class Server {
return path.resolve(dir, `node_modules/.cache/${pluginName}`);
}

/**
* The compiler whose configuration the dev server belongs to. For a
* `MultiCompiler` that is the child naming `devServer`, else the one
* targeting the web, else the first — anything else reads another child's
* settings.
* @private
* @param {Compiler | MultiCompiler} compiler compiler
* @returns {Compiler} the compiler that owns the dev server
*/
static findDevServerCompiler(compiler) {
const { compilers } = /** @type {MultiCompiler} */ (compiler);

if (typeof compilers === "undefined") {
return /** @type {Compiler} */ (compiler);
}

if (compilers.length === 1) {
return compilers[0];
}

return (
compilers.find((child) => child.options.devServer) ||
compilers.find((child) => Server.isWebTarget(child)) ||
compilers[0]
);
}

/**
* Throw unless the options match the schema.
*
* `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. Validating through the compiler that
* owns the dev server matters: `compiler.validate` honours that compiler's
* `validate` option, so asking any other child would read a policy that was
* never about these options. Either way the schema is only read, and ajv only
* compiled, once something is actually wrong — the precompiled validator
* answers the common case in ~1ms, against the ~120ms `schema-utils` spends
* compiling the schema on its first call.
* @private
* @param {Compiler | MultiCompiler} compiler compiler that owns the dev server
* @param {EXPECTED_ANY} options options
* @returns {void}
*/
static validateOptions(compiler, options) {
const owner = Server.findDevServerCompiler(compiler);

if (owner.hooks.validate) {
/** @type {EXPECTED_ANY} */
(owner).validate(
() => schema,
options,
{ name: "Dev Server", baseDataPath: "options" },
schemaCheck,
);

return;
}

// TODO remove this fallback in the next major, once the minimum supported
// webpack has `compiler.hooks.validate`, and validate through the compiler
// alone.
if (!schemaCheck(options)) {
validate(/** @type {Schema} */ (schema), options, {
name: "Dev Server",
baseDataPath: "options",
});
}
}

/**
* @private
* @param {Compiler} compiler compiler
Expand Down Expand Up @@ -792,40 +860,9 @@ class Server {
* @returns {Compiler["options"]} compiler options
*/
getCompilerOptions() {
if (
typeof (/** @type {MultiCompiler} */ (this.compiler).compilers) !==
"undefined"
) {
if (/** @type {MultiCompiler} */ (this.compiler).compilers.length === 1) {
return (
/** @type {MultiCompiler} */
(this.compiler).compilers[0].options
);
}

// Configuration with the `devServer` options
const compilerWithDevServer =
/** @type {MultiCompiler} */
(this.compiler).compilers.find((config) => config.options.devServer);

if (compilerWithDevServer) {
return compilerWithDevServer.options;
}

// Configuration with `web` preset
const compilerWithWebPreset =
/** @type {MultiCompiler} */
(this.compiler).compilers.find((config) => Server.isWebTarget(config));

if (compilerWithWebPreset) {
return compilerWithWebPreset.options;
}

// Fallback
return /** @type {MultiCompiler} */ (this.compiler).compilers[0].options;
}

return /** @type {Compiler} */ (this.compiler).options;
return Server.findDevServerCompiler(
/** @type {Compiler | MultiCompiler} */ (this.compiler),
).options;
}

/**
Expand Down Expand Up @@ -3761,6 +3798,11 @@ class Server {
* @returns {void}
*/
apply(compiler) {
// As a plugin the server is constructed without a compiler, so validation
// waits for the one that will own it — otherwise that compiler's `validate`
// option would have no say over the options it is about.
Server.validateOptions(compiler, this.options);

this.compiler = compiler;
this.isPlugin = true;
this.logger = this.compiler.getInfrastructureLogger(pluginName);
Expand Down
5 changes: 5 additions & 0 deletions lib/options.check.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"lint": "npm-run-all -l -p \"lint:**\"",
"fix:code": "npm run lint:code -- --fix",
"fix:prettier": "npm run lint:prettier -- --write",
"fix": "npm-run-all -l fix:code fix:prettier",
"fix": "npm-run-all -l fix:code fix:schema-check fix:prettier",
"validate:changeset": "node .changeset/changeset-validate.mjs",
"build:cjs": "rimraf -g ./dist/* && babel lib --out-dir dist --env-name cjs --copy-files --no-copy-ignored && node ./scripts/finalize-cjs-build.mjs",
"build:client": "rimraf -g ./client/* && babel client-src/ --out-dir client/ --ignore \"client-src/webpack.config.js\" --ignore \"client-src/modules\" && webpack --config client-src/webpack.config.js",
Expand All @@ -60,7 +60,9 @@
"test:watch": "npm run test:only -- --watch",
"test": "npm run test:coverage",
"pretest": "npm run lint",
"prepare": "husky && npm run build"
"prepare": "husky && npm run build",
"lint:schema-check": "node ./scripts/generate-schema-check.mjs --check",
"fix:schema-check": "node ./scripts/generate-schema-check.mjs"
},
"dependencies": {
"@types/bonjour": "^3.5.13",
Expand Down Expand Up @@ -102,6 +104,7 @@
"@types/picomatch": "^4.0.2",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.14.0",
"ajv": "^8.20.0",
"babel-loader": "^10.0.0",
"babel-plugin-transform-import-meta": "^3.0.0",
"connect": "^3.7.0",
Expand Down
170 changes: 170 additions & 0 deletions scripts/generate-schema-check.mjs
Original file line number Diff line number Diff line change
@@ -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 `lib/options.json` into a standalone validator, `lib/options.check.js`.
//
// `schema-utils`'s `validate()` compiles the schema with ajv on its first call,
// which cost ~120ms of every `new Server()` — a one-time startup price paid by
// every user on every run. The generated validator answers the same question
// with no compile step, so the happy path never loads ajv at all; `Server` only
// falls back to `schema-utils` when this validator rejects, 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, "lib", "options.json");
const OUTPUT_PATH = path.join(ROOT, "lib", "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<string, unknown>} 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: true },
});

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);
}
Loading
Loading