v8r 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +5 -3
- package/src/bootstrap.js +2 -0
- package/src/cache-prewarm.js +87 -0
- package/src/cache.js +42 -17
- package/src/catalogs.js +3 -3
- package/src/cli.js +8 -1
- package/src/parser.js +3 -0
- package/src/test-helpers.js +0 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 📦 [5.1.0](https://www.npmjs.com/package/v8r/v/5.1.0) - 2025-07-20
|
|
4
|
+
|
|
5
|
+
* v8r now pre-warms the cache and fetches schemas in parallel.
|
|
6
|
+
This will improve decrease total run time for any run that involves fetching
|
|
7
|
+
more than one remote schema, or involves a schema with remote `$ref`s.
|
|
8
|
+
* Improve handling of empty yaml files.
|
|
9
|
+
|
|
3
10
|
## 📦 [5.0.0](https://www.npmjs.com/package/v8r/v/5.0.0) - 2025-05-10
|
|
4
11
|
|
|
5
12
|
Following on from the deprecations in version 4.4.0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "v8r",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "A command-line JSON, YAML and TOML validator that's on your wavelength",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "V8R_CACHE_NAME=v8r-test c8 --reporter=text mocha \"src/**/*.spec.js\"",
|
|
@@ -42,14 +42,16 @@
|
|
|
42
42
|
"js-yaml": "^4.0.0",
|
|
43
43
|
"json5": "^2.2.0",
|
|
44
44
|
"minimatch": "^10.0.0",
|
|
45
|
+
"p-limit": "^6.2.0",
|
|
46
|
+
"p-mutex": "^1.0.0",
|
|
45
47
|
"smol-toml": "^1.0.1",
|
|
46
|
-
"yargs": "^
|
|
48
|
+
"yargs": "^18.0.0"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
49
51
|
"c8": "^10.1.2",
|
|
50
52
|
"eslint": "^9.9.0",
|
|
51
53
|
"eslint-config-prettier": "^10.1.2",
|
|
52
|
-
"eslint-plugin-jsdoc": "^
|
|
54
|
+
"eslint-plugin-jsdoc": "^51.4.1",
|
|
53
55
|
"eslint-plugin-mocha": "^11.0.0",
|
|
54
56
|
"eslint-plugin-prettier": "^5.0.0",
|
|
55
57
|
"mocha": "^11.0.1",
|
package/src/bootstrap.js
CHANGED
|
@@ -53,6 +53,8 @@ function mergeConfigs(args, config) {
|
|
|
53
53
|
if (config.filepath) {
|
|
54
54
|
mergedConfig.configFileRelativePath = getRelativeFilePath(config);
|
|
55
55
|
}
|
|
56
|
+
// hard-coded - this can't be set via CLI or config file
|
|
57
|
+
mergedConfig.cachePrewarm = true;
|
|
56
58
|
return mergedConfig;
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import isUrl from "is-url";
|
|
2
|
+
import pLimit from "p-limit";
|
|
3
|
+
import { getCatalogs, getMatchForFilename } from "./catalogs.js";
|
|
4
|
+
|
|
5
|
+
const limit = pLimit(10);
|
|
6
|
+
|
|
7
|
+
async function fetch(location, cache) {
|
|
8
|
+
return await cache.fetch(location, false);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fetchWithLimit(url, cache) {
|
|
12
|
+
return limit(() => fetch(url, cache));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeUrl(ref) {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(ref);
|
|
18
|
+
url.hash = "";
|
|
19
|
+
return url.toString();
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getRemoteRefs(node) {
|
|
26
|
+
let refs = [];
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
for (const v of node) {
|
|
29
|
+
if (typeof v === "object" && v !== null) {
|
|
30
|
+
refs = refs.concat(getRemoteRefs(v));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} else if (typeof node === "object" && node !== null) {
|
|
34
|
+
for (const [k, v] of Object.entries(node)) {
|
|
35
|
+
if (k === "$ref" && typeof v === "string") {
|
|
36
|
+
const resolved = normalizeUrl(v);
|
|
37
|
+
if (resolved && isUrl(resolved)) {
|
|
38
|
+
refs.push(resolved);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof v === "object" && v !== null) {
|
|
42
|
+
refs = refs.concat(getRemoteRefs(v));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return Array.from(new Set(refs));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fetchAndRecurse(url, cache) {
|
|
50
|
+
const schema = await fetchWithLimit(url, cache);
|
|
51
|
+
const refs = getRemoteRefs(schema).filter(
|
|
52
|
+
(ref) => cache.get(ref) === undefined,
|
|
53
|
+
);
|
|
54
|
+
await Promise.all(refs.map((ref) => fetchAndRecurse(ref, cache)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function prewarmSchemaCache(filenames, config, cache) {
|
|
58
|
+
const catalogs = getCatalogs(config);
|
|
59
|
+
const schemaLocations = new Set();
|
|
60
|
+
|
|
61
|
+
for (const filename of filenames) {
|
|
62
|
+
let catalogMatch;
|
|
63
|
+
try {
|
|
64
|
+
catalogMatch = config.schema
|
|
65
|
+
? {}
|
|
66
|
+
: await getMatchForFilename(catalogs, filename, "debug", cache);
|
|
67
|
+
} catch {
|
|
68
|
+
catalogMatch = {};
|
|
69
|
+
}
|
|
70
|
+
const schemaLocation = config.schema || catalogMatch.location;
|
|
71
|
+
|
|
72
|
+
if (schemaLocation) {
|
|
73
|
+
schemaLocations.add(schemaLocation);
|
|
74
|
+
}
|
|
75
|
+
cache.resetCounters();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await Promise.all(
|
|
79
|
+
Array.from(schemaLocations)
|
|
80
|
+
.filter((schemaLocation) => isUrl(schemaLocation))
|
|
81
|
+
.map((url) => fetchAndRecurse(url, cache)),
|
|
82
|
+
);
|
|
83
|
+
cache.persist();
|
|
84
|
+
cache.resetCounters();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { getRemoteRefs, prewarmSchemaCache, normalizeUrl };
|
package/src/cache.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import got from "got";
|
|
2
|
+
import Mutex from "p-mutex";
|
|
2
3
|
import logger from "./logger.js";
|
|
3
4
|
import { parseSchema } from "./parser.js";
|
|
4
5
|
|
|
@@ -7,39 +8,53 @@ class Cache {
|
|
|
7
8
|
this.cache = flatCache;
|
|
8
9
|
this.ttl = this.cache._cache.ttl || 0;
|
|
9
10
|
this.callCounter = {};
|
|
11
|
+
this.locks = {};
|
|
10
12
|
this.callLimit = 10;
|
|
11
13
|
if (this.ttl === 0) {
|
|
12
14
|
this.cache.clear();
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
getMutex(url) {
|
|
19
|
+
if (!(url in this.locks)) {
|
|
20
|
+
this.locks[url] = new Mutex();
|
|
21
|
+
}
|
|
22
|
+
return this.locks[url];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async limitDepth(url) {
|
|
17
26
|
/*
|
|
18
27
|
It is possible to create cyclic dependencies with external references
|
|
19
|
-
in JSON schema.
|
|
28
|
+
in JSON schema.
|
|
29
|
+
We try to mitigate this issue during cache pre-warming.
|
|
30
|
+
Ajv doesn't detect this when resolving external references,
|
|
20
31
|
so we keep a count of how many times we've called the same URL.
|
|
21
32
|
If we are calling the same URL over and over we've probably hit a circular
|
|
22
33
|
external reference and we need to break the loop.
|
|
23
34
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
this.callCounter
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
)
|
|
33
|
-
|
|
35
|
+
const mutex = this.getMutex(url);
|
|
36
|
+
|
|
37
|
+
await mutex.withLock(async () => {
|
|
38
|
+
if (url in this.callCounter) {
|
|
39
|
+
this.callCounter[url]++;
|
|
40
|
+
} else {
|
|
41
|
+
this.callCounter[url] = 1;
|
|
42
|
+
}
|
|
43
|
+
if (this.callCounter[url] > this.callLimit) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Called ${url} >${this.callLimit} times. Possible circular reference.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
resetCounters() {
|
|
37
52
|
this.callCounter = {};
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
async fetch(url) {
|
|
41
|
-
this.limitDepth(url);
|
|
42
|
-
const cachedResponse = this.cache.
|
|
55
|
+
async fetch(url, persist = true) {
|
|
56
|
+
await this.limitDepth(url);
|
|
57
|
+
const cachedResponse = this.cache.get(url);
|
|
43
58
|
if (cachedResponse !== undefined) {
|
|
44
59
|
logger.debug(`Cache hit: using cached response from ${url}`);
|
|
45
60
|
return cachedResponse.body;
|
|
@@ -50,8 +65,10 @@ class Cache {
|
|
|
50
65
|
const resp = await got(url);
|
|
51
66
|
const parsedBody = parseSchema(resp.body, url);
|
|
52
67
|
if (this.ttl > 0) {
|
|
53
|
-
this.cache.
|
|
54
|
-
|
|
68
|
+
this.cache.set(url, { body: parsedBody });
|
|
69
|
+
if (persist) {
|
|
70
|
+
this.cache.save(true);
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
return parsedBody;
|
|
57
74
|
} catch (error) {
|
|
@@ -61,6 +78,14 @@ class Cache {
|
|
|
61
78
|
throw new Error(`Failed fetching ${url}`);
|
|
62
79
|
}
|
|
63
80
|
}
|
|
81
|
+
|
|
82
|
+
persist() {
|
|
83
|
+
this.cache.save(true);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get(key) {
|
|
87
|
+
return this.cache.get(key);
|
|
88
|
+
}
|
|
64
89
|
}
|
|
65
90
|
|
|
66
91
|
export { Cache };
|
package/src/catalogs.js
CHANGED
|
@@ -74,7 +74,7 @@ function getMultipleMatchesLogMessage(matches) {
|
|
|
74
74
|
.join("\n");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
async function getMatchForFilename(catalogs, filename, cache) {
|
|
77
|
+
async function getMatchForFilename(catalogs, filename, logLevel, cache) {
|
|
78
78
|
for (const [i, rec] of catalogs.entries()) {
|
|
79
79
|
const catalogLocation = rec.location;
|
|
80
80
|
const catalog =
|
|
@@ -107,7 +107,7 @@ async function getMatchForFilename(catalogs, filename, cache) {
|
|
|
107
107
|
(matches.length === 1 && matches[0].versions == null) ||
|
|
108
108
|
(matches.length === 1 && Object.keys(matches[0].versions).length === 1)
|
|
109
109
|
) {
|
|
110
|
-
logger
|
|
110
|
+
logger[logLevel](`Found schema in ${catalogLocation} ...`);
|
|
111
111
|
return coerceMatch(matches[0]); // Exactly one match found. We're done.
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -122,7 +122,7 @@ async function getMatchForFilename(catalogs, filename, cache) {
|
|
|
122
122
|
) {
|
|
123
123
|
// We found >1 matches in the same catalog. This is always a hard error.
|
|
124
124
|
const matchesLog = getMultipleMatchesLogMessage(matches);
|
|
125
|
-
logger
|
|
125
|
+
logger[logLevel](
|
|
126
126
|
`Found multiple possible matches for ${filename}. Possible matches:\n\n${matchesLog}`,
|
|
127
127
|
);
|
|
128
128
|
throw new Error(
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { getFromUrlOrFile } from "./io.js";
|
|
|
12
12
|
import logger from "./logger.js";
|
|
13
13
|
import { getDocumentLocation } from "./output-formatters.js";
|
|
14
14
|
import { parseFile } from "./parser.js";
|
|
15
|
+
import { prewarmSchemaCache } from "./cache-prewarm.js";
|
|
15
16
|
|
|
16
17
|
const EXIT = {
|
|
17
18
|
VALID: 0,
|
|
@@ -92,7 +93,7 @@ async function validateFile(filename, config, plugins, cache) {
|
|
|
92
93
|
const catalogs = getCatalogs(config);
|
|
93
94
|
const catalogMatch = config.schema
|
|
94
95
|
? {}
|
|
95
|
-
: await getMatchForFilename(catalogs, filename, cache);
|
|
96
|
+
: await getMatchForFilename(catalogs, filename, "info", cache);
|
|
96
97
|
schemaLocation = config.schema || catalogMatch.location;
|
|
97
98
|
schema = await getFromUrlOrFile(schemaLocation, cache);
|
|
98
99
|
logger.info(
|
|
@@ -182,6 +183,12 @@ function Validator() {
|
|
|
182
183
|
const ttl = secondsToMilliseconds(config.cacheTtl || 0);
|
|
183
184
|
const cache = new Cache(getFlatCache(ttl));
|
|
184
185
|
|
|
186
|
+
if (config.cachePrewarm && ttl > 5000) {
|
|
187
|
+
logger.info("Pre-warming the cache");
|
|
188
|
+
await prewarmSchemaCache(filenames, config, cache);
|
|
189
|
+
logger.debug("Cache pre-warming complete");
|
|
190
|
+
}
|
|
191
|
+
|
|
185
192
|
let results = [];
|
|
186
193
|
for (const filename of filenames) {
|
|
187
194
|
const fileResults = await validateFile(filename, config, plugins, cache);
|
package/src/parser.js
CHANGED
|
@@ -10,6 +10,9 @@ function parseFile(plugins, contents, filename, parser) {
|
|
|
10
10
|
const maybeDocuments = Array.isArray(parsedFile)
|
|
11
11
|
? parsedFile
|
|
12
12
|
: [parsedFile];
|
|
13
|
+
if (maybeDocuments.length === 0) {
|
|
14
|
+
throw new Error(`No documents to validate found in ${filename}`);
|
|
15
|
+
}
|
|
13
16
|
for (const doc of maybeDocuments) {
|
|
14
17
|
if (!(doc instanceof Document)) {
|
|
15
18
|
throw new Error(
|
package/src/test-helpers.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { clearCacheById } from "flat-cache";
|
|
2
1
|
import logger from "./logger.js";
|
|
3
2
|
|
|
4
3
|
const origWriteOut = logger.writeOut;
|
|
@@ -7,7 +6,6 @@ const testCacheName = process.env.V8R_CACHE_NAME;
|
|
|
7
6
|
const env = process.env;
|
|
8
7
|
|
|
9
8
|
function setUp() {
|
|
10
|
-
clearCacheById(testCacheName);
|
|
11
9
|
logger.resetStdout();
|
|
12
10
|
logger.resetStderr();
|
|
13
11
|
logger.writeOut = function () {};
|
|
@@ -16,7 +14,6 @@ function setUp() {
|
|
|
16
14
|
}
|
|
17
15
|
|
|
18
16
|
function tearDown() {
|
|
19
|
-
clearCacheById(testCacheName);
|
|
20
17
|
logger.resetStdout();
|
|
21
18
|
logger.resetStderr();
|
|
22
19
|
logger.writeOut = origWriteOut;
|