Skip to content

Commit 43f0ec3

Browse files
authored
fix(spec): pre-compile JSON schema validators at build time (#5039)
The `@jsii/spec` package currently compiles JSON schemas into validators at runtime using `ajv`. This happens every time the validators are called, which adds unnecessary overhead during jsii compilation and when loading assemblies. Every now and then we are also seeing users getting the following error. While I am not entirely clear on what is causing it (maybe some confused dependency tree?), getting rid of `ajv` completely will solve this. ``` TypeError: ajv_1.default is not a constructor at loadAssemblyFromFile (node_modules\@jsii\spec\lib\assembly-utils.js:139:15) ``` This change pre-compiles the validators during the build process using [Ajv's standalone code generation feature](https://ajv.js.org/standalone.html). The generated JavaScript code is a self-contained validator that doesn't require the `ajv` library at runtime. This approach has two benefits: it eliminates the schema compilation overhead at runtime, and it allows us to move `ajv` from a runtime dependency to a dev dependency, reducing the package's footprint. While making these changes, I also noticed that `fs-extra` was only used in tests for convenience methods like `readJsonSync` and `removeSync`. Since Node.js 14+ provides `fs.rmSync` with recursive support, and JSON parsing is trivial with `JSON.parse(fs.readFileSync(...))`, the `fs-extra` dependency is no longer needed and has been removed entirely. --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
1 parent 1d8bf16 commit 43f0ec3

File tree

9 files changed

+95
-53
lines changed

9 files changed

+95
-53
lines changed

packages/@jsii/spec/.eslintrc.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
---
22
extends: ../../../eslint-config.yaml
3+
4+
ignorePatterns:
5+
- build-tools/generate-validators.js

packages/@jsii/spec/build-tools/generate-json-schema.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ set -euo pipefail
4040
--strictNullChecks true \
4141
--topRef true
4242
}
43+
44+
# Generate standalone validation code from schemas
45+
echo "Generating standalone validation code"
46+
node build-tools/generate-validators.js
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const Ajv = require('ajv');
2+
const standaloneCode = require('ajv/dist/standalone').default;
3+
const { readFileSync, writeFileSync } = require('fs');
4+
5+
const ajv = new Ajv({ code: { source: true, esm: false }, allErrors: true });
6+
7+
// Load and compile schemas
8+
const assemblySchema = JSON.parse(readFileSync('schema/jsii-spec.schema.json', 'utf8'));
9+
const redirectSchema = JSON.parse(readFileSync('schema/assembly-redirect.schema.json', 'utf8'));
10+
11+
ajv.addSchema(assemblySchema, 'assembly');
12+
ajv.addSchema(redirectSchema, 'redirect');
13+
14+
// Generate standalone code
15+
const code = standaloneCode(ajv, {
16+
validateAssembly: 'assembly',
17+
validateRedirect: 'redirect',
18+
});
19+
20+
writeFileSync('lib/validators.js', code);
21+
console.log('Generated lib/validators.js');
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { overriddenConfig } from '../../../jest.config.mjs';
22

33
export default overriddenConfig({
4+
coveragePathIgnorePatterns: [
5+
'lib/validators.js'
6+
],
47
coverageThreshold: {
58
global: {
6-
branches: 28,
9+
branches: 60,
710
},
811
},
912
});

packages/@jsii/spec/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,8 @@
3030
"test:update": "jest -u",
3131
"package": "package-js"
3232
},
33-
"dependencies": {
34-
"ajv": "^8.17.1"
35-
},
3633
"devDependencies": {
37-
"fs-extra": "^10.1.0",
34+
"ajv": "^8.17.1",
3835
"jsii-build-tools": "^0.0.0",
3936
"typescript-json-schema": "^0.65.1"
4037
}

packages/@jsii/spec/src/assembly-utils.test.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as fs from 'fs-extra';
1+
import * as fs from 'node:fs';
22
import * as os from 'os';
33
import * as path from 'path';
44
import * as zlib from 'zlib';
@@ -44,7 +44,7 @@ beforeEach(() => {
4444
});
4545

4646
afterEach(() => {
47-
fs.removeSync(tmpdir);
47+
fs.rmSync(tmpdir, { recursive: true, force: true });
4848
});
4949

5050
describe(writeAssembly, () => {
@@ -56,9 +56,11 @@ describe(writeAssembly, () => {
5656
).toBeTruthy();
5757

5858
// includes .jsii files with instructions for finding compressed file
59-
const instructions = fs.readJsonSync(path.join(tmpdir, SPEC_FILE_NAME), {
60-
encoding: 'utf-8',
61-
});
59+
const instructions = JSON.parse(
60+
fs.readFileSync(path.join(tmpdir, SPEC_FILE_NAME), {
61+
encoding: 'utf-8',
62+
}),
63+
);
6264
expect(instructions).toEqual({
6365
schema: 'jsii/file-redirect',
6466
compression: 'gzip',
@@ -117,42 +119,57 @@ describe(loadAssemblyFromPath, () => {
117119
loadAssemblyFromPath(uncompressedTmpDir),
118120
);
119121

120-
fs.removeSync(compressedTmpDir);
121-
fs.removeSync(uncompressedTmpDir);
122+
fs.rmSync(compressedTmpDir, { recursive: true, force: true });
123+
fs.rmSync(uncompressedTmpDir, { recursive: true, force: true });
122124
});
123125

124126
test('throws if redirect object has unsupported compression', () => {
125-
fs.writeJsonSync(path.join(tmpdir, SPEC_FILE_NAME), {
126-
schema: 'jsii/file-redirect',
127-
compression: '7zip',
128-
filename: '.jsii.7z',
129-
});
127+
fs.writeFileSync(
128+
path.join(tmpdir, SPEC_FILE_NAME),
129+
JSON.stringify(
130+
{
131+
schema: 'jsii/file-redirect',
132+
compression: '7zip',
133+
filename: '.jsii.7z',
134+
},
135+
null,
136+
2,
137+
),
138+
);
130139

131140
expect(() => loadAssemblyFromPath(tmpdir)).toThrow(
132141
/Error: Invalid assembly redirect:\n \* redirect\/compression must be equal to constant/m,
133142
);
134143
});
135144

136145
test('throws if redirect object is missing filename', () => {
137-
fs.writeJsonSync(path.join(tmpdir, SPEC_FILE_NAME), {
138-
schema: 'jsii/file-redirect',
139-
});
146+
fs.writeFileSync(
147+
path.join(tmpdir, SPEC_FILE_NAME),
148+
JSON.stringify(
149+
{
150+
schema: 'jsii/file-redirect',
151+
},
152+
null,
153+
2,
154+
),
155+
);
140156

141157
expect(() => loadAssemblyFromPath(tmpdir)).toThrow(
142158
/Error: Invalid assembly redirect:\n \* redirect must have required property 'filename'/m,
143159
);
144160
});
145161

146162
test('throws if assembly is invalid', () => {
147-
fs.writeJsonSync(
163+
fs.writeFileSync(
148164
path.join(tmpdir, SPEC_FILE_NAME),
149-
{
150-
assembly: 'not a valid assembly',
151-
},
152-
{
153-
encoding: 'utf8',
154-
spaces: 2,
155-
},
165+
JSON.stringify(
166+
{
167+
assembly: 'not a valid assembly',
168+
},
169+
null,
170+
2,
171+
),
172+
{ encoding: 'utf8' },
156173
);
157174

158175
expect(() => loadAssemblyFromPath(tmpdir)).toThrow(/Invalid assembly/);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ErrorObject } from 'ajv';
2+
3+
export function formatErrors(errors: ErrorObject[], dataVar: string): string {
4+
if (!errors || errors.length === 0) {
5+
return 'No errors';
6+
}
7+
return errors
8+
.map((e) => `${dataVar}${e.instancePath} ${e.message}`)
9+
.join('\n * ');
10+
}

packages/@jsii/spec/src/redirect.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import Ajv from 'ajv';
1+
import { formatErrors } from './format-errors';
22

33
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
44
export const assemblyRedirectSchema = require('../schema/assembly-redirect.schema.json');
55

6+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
7+
const { validateRedirect: validateSchema } = require('../lib/validators');
8+
69
const SCHEMA = 'jsii/file-redirect';
710

811
export interface AssemblyRedirect {
@@ -43,18 +46,9 @@ export function isAssemblyRedirect(obj: unknown): obj is AssemblyRedirect {
4346
* @returns the validated value.
4447
*/
4548
export function validateAssemblyRedirect(obj: unknown): AssemblyRedirect {
46-
const ajv = new Ajv({
47-
allErrors: true,
48-
});
49-
const validate = ajv.compile(assemblyRedirectSchema);
50-
validate(obj);
51-
52-
if (validate.errors) {
49+
if (!validateSchema(obj)) {
5350
throw new Error(
54-
`Invalid assembly redirect:\n * ${ajv.errorsText(validate.errors, {
55-
separator: '\n * ',
56-
dataVar: 'redirect',
57-
})}`,
51+
`Invalid assembly redirect:\n * ${formatErrors(validateSchema.errors, 'redirect')}`,
5852
);
5953
}
6054

packages/@jsii/spec/src/validate-assembly.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
1-
import Ajv from 'ajv';
2-
31
import { Assembly } from './assembly';
2+
import { formatErrors } from './format-errors';
43

54
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
65
export const schema = require('../schema/jsii-spec.schema.json');
76

8-
export function validateAssembly(obj: any): Assembly {
9-
const ajv = new Ajv({
10-
allErrors: true,
11-
});
12-
const validate = ajv.compile(schema);
13-
validate(obj);
7+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
8+
const { validateAssembly: validateSchema } = require('../lib/validators');
149

15-
if (validate.errors) {
10+
export function validateAssembly(obj: any): Assembly {
11+
if (!validateSchema(obj)) {
1612
let descr = '';
1713
if (typeof obj.name === 'string' && obj.name !== '') {
1814
descr =
@@ -21,10 +17,7 @@ export function validateAssembly(obj: any): Assembly {
2117
: ` ${obj.name}`;
2218
}
2319
throw new Error(
24-
`Invalid assembly${descr}:\n * ${ajv.errorsText(validate.errors, {
25-
separator: '\n * ',
26-
dataVar: 'assembly',
27-
})}`,
20+
`Invalid assembly${descr}:\n * ${formatErrors(validateSchema.errors, 'assembly')}`,
2821
);
2922
}
3023
return obj;

0 commit comments

Comments
 (0)