perf: construct the default Ajv engine lazily on first validation (#2458)
This commit is contained in:
parent
e0a0ab74d9
commit
7c49b47fb3
3 changed files with 78 additions and 7 deletions
6
.changeset/lazy-ajv-engine.md
Normal file
6
.changeset/lazy-ajv-engine.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': patch
|
||||
'@modelcontextprotocol/server': patch
|
||||
---
|
||||
|
||||
Construct the default Ajv validation engine lazily on first validation. Creating a `Client` or `Server` no longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs.
|
||||
|
|
@ -79,7 +79,7 @@ function createDefaultAjvInstance(): AjvLike {
|
|||
* ```
|
||||
*/
|
||||
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
|
||||
private readonly _ajv: AjvLike;
|
||||
private _ajv: AjvLike | undefined;
|
||||
/** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */
|
||||
private readonly _userAjv: boolean;
|
||||
|
||||
|
|
@ -88,12 +88,19 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
|
|||
* used for **every** schema regardless of its declared `$schema` (the caller owns dialect
|
||||
* choice). When omitted, the provider constructs a single `Ajv2020` instance with
|
||||
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
|
||||
* `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass an
|
||||
* instance need not have `ajv` installed.
|
||||
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so
|
||||
* constructing the provider (e.g. as the default validator of a `Client`/`Server` that never
|
||||
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter
|
||||
* is typed structurally so consumers who don't pass an instance need not have `ajv` installed.
|
||||
*/
|
||||
constructor(ajv?: AjvLike) {
|
||||
this._userAjv = ajv !== undefined;
|
||||
this._ajv = ajv ?? createDefaultAjvInstance();
|
||||
this._ajv = ajv;
|
||||
}
|
||||
|
||||
/** The underlying engine — the default instance is created on first use. */
|
||||
private get ajv(): AjvLike {
|
||||
return (this._ajv ??= createDefaultAjvInstance());
|
||||
}
|
||||
|
||||
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
|
||||
|
|
@ -113,10 +120,11 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
|
|||
);
|
||||
}
|
||||
|
||||
const engine = this.ajv;
|
||||
const ajvValidator =
|
||||
'$id' in schema && typeof schema.$id === 'string'
|
||||
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
|
||||
: this._ajv.compile(schema);
|
||||
? (engine.getSchema(schema.$id) ?? engine.compile(schema))
|
||||
: engine.compile(schema);
|
||||
|
||||
return (input: unknown): JsonSchemaValidatorResult<T> => {
|
||||
const valid = ajvValidator(input);
|
||||
|
|
@ -130,7 +138,7 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
|
|||
: {
|
||||
valid: false,
|
||||
data: undefined,
|
||||
errorMessage: this._ajv.errorsText(ajvValidator.errors)
|
||||
errorMessage: engine.errorsText(ajvValidator.errors)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
57
packages/core-internal/test/validators/ajvLazyInit.test.ts
Normal file
57
packages/core-internal/test/validators/ajvLazyInit.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
|
||||
|
||||
/** Peeks at the provider's private engine slot without triggering the lazy getter. */
|
||||
function engineSlot(provider: AjvJsonSchemaValidator): unknown {
|
||||
return (provider as unknown as { _ajv: unknown })._ajv;
|
||||
}
|
||||
|
||||
describe('AjvJsonSchemaValidator lazy engine construction', () => {
|
||||
it('does not construct the default Ajv engine until the first getValidator call', () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
expect(engineSlot(provider)).toBeUndefined();
|
||||
|
||||
const validate = provider.getValidator<{ a?: string }>({ type: 'object', properties: { a: { type: 'string' } } });
|
||||
expect(engineSlot(provider)).toBeDefined();
|
||||
expect(validate({ a: 'x' }).valid).toBe(true);
|
||||
expect(validate({ a: 1 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses one engine across getValidator calls', () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
provider.getValidator({ type: 'string' });
|
||||
const first = engineSlot(provider);
|
||||
provider.getValidator({ type: 'number' });
|
||||
expect(engineSlot(provider)).toBe(first);
|
||||
});
|
||||
|
||||
it('uses a caller-supplied engine immediately and never replaces it', () => {
|
||||
let compiles = 0;
|
||||
const fake = {
|
||||
compile: () => {
|
||||
compiles += 1;
|
||||
return Object.assign(() => true, { errors: undefined });
|
||||
},
|
||||
getSchema: () => undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
errorsText: (_errors?: any) => ''
|
||||
};
|
||||
const provider = new AjvJsonSchemaValidator(fake);
|
||||
expect(engineSlot(provider)).toBe(fake);
|
||||
|
||||
const validate = provider.getValidator({ type: 'object' });
|
||||
expect(compiles).toBe(1);
|
||||
expect(validate({}).valid).toBe(true);
|
||||
expect(engineSlot(provider)).toBe(fake);
|
||||
});
|
||||
|
||||
it('still rejects non-2020-12 $schema dialects before constructing the default engine', () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' })).toThrow(
|
||||
/unsupported dialect/
|
||||
);
|
||||
// The dialect check fires before engine construction — no engine was built for the rejected schema.
|
||||
expect(engineSlot(provider)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue