fast-json-stringify 7.1.2 → 7.1.4

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/README.md CHANGED
@@ -127,7 +127,7 @@ const stringify = fastJson(mySchema, {
127
127
  ```
128
128
 
129
129
  - `schema`: external schemas references by $ref property. [More details](#ref)
130
- - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof)
130
+ - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof) Schema strictness defaults to `false`; pass `ajv: { strictSchema: true }` to reject unknown schema keywords or `ajv: { strictSchema: 'log' }` to log warnings. This applies only to schema paths compiled by Ajv, not to every schema processed by fast-json-stringify.
131
131
  - `rounding`: setup how the `integer` types will be rounded when not integers. [More details](#integer)
132
132
  - `maxDepth`: maximum number of nested schema levels allowed during compilation.
133
133
  Defaults to `100` and may be set from `0` to `100`. Schemas that exceed this limit
package/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  /* eslint no-prototype-builtins: 0 */
4
4
 
5
5
  const { RefResolver } = require('json-schema-ref-resolver')
6
+ const { MergeError } = require('@fastify/merge-json-schemas')
6
7
 
7
8
  const Serializer = require('./lib/serializer')
8
9
  const Validator = require('./lib/validator')
@@ -13,7 +14,7 @@ const mergeSchemas = require('./lib/merge-schemas')
13
14
  const DEFAULT_MAX_DEPTH = 100
14
15
  const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
15
16
 
16
- // Minimum array lengths at which copying into a null-prototype array and
17
+ // Minimum array lengths at which copying into a plain array and
17
18
  // calling native JSON.stringify beats the generated per-item loop. Measured
18
19
  // against main with tinybench (alternating rounds, median ops/s, output
19
20
  // consumed with Buffer.byteLength) on Node 20, 22, 24 and 26 x64.
@@ -87,6 +88,19 @@ const validLargeArrayMechanisms = new Set([
87
88
  'json-stringify'
88
89
  ])
89
90
 
91
+ // Keywords that do not change the output, so a $ref carrying only these
92
+ // can still be dereferenced directly instead of merged with its target.
93
+ const IGNORED_REF_SIBLING_KEYWORDS = new Set([
94
+ '$ref',
95
+ '$comment',
96
+ 'title',
97
+ 'description',
98
+ 'examples',
99
+ 'deprecated',
100
+ 'readOnly',
101
+ 'writeOnly'
102
+ ])
103
+
90
104
  let schemaIdCounter = 0
91
105
 
92
106
  function getMaxDepth (options) {
@@ -223,10 +237,12 @@ function isValidSchema (schema, name) {
223
237
 
224
238
  function resolveRef (context, location) {
225
239
  const seen = new Set()
240
+ const siblingLocations = []
226
241
  let depth = 0
227
242
 
228
243
  while (location.schema.$ref !== undefined) {
229
- const ref = location.schema.$ref
244
+ const refSchema = location.schema
245
+ const ref = refSchema.$ref
230
246
  const locationRef = location.getSchemaRef()
231
247
 
232
248
  if (seen.has(locationRef)) {
@@ -251,10 +267,45 @@ function resolveRef (context, location) {
251
267
  throw new Error(`Cannot find reference "${ref}"`)
252
268
  }
253
269
 
270
+ const siblingSchema = {}
271
+ for (const key in refSchema) {
272
+ if (!IGNORED_REF_SIBLING_KEYWORDS.has(key)) {
273
+ siblingSchema[key] = refSchema[key]
274
+ }
275
+ }
276
+
277
+ if (Object.keys(siblingSchema).length !== 0) {
278
+ siblingLocations.unshift(new Location(
279
+ cloneOriginSchema(context, siblingSchema, location.schemaId),
280
+ location.schemaId,
281
+ location.jsonPointer
282
+ ))
283
+ }
284
+
254
285
  location = new Location(schema, schemaId, jsonPointer)
255
286
  }
256
287
 
257
- return location
288
+ if (siblingLocations.length === 0) {
289
+ return location
290
+ }
291
+
292
+ // Keyed by content, not by identity: a merged schema is cloned on every
293
+ // merge, so a recursive $ref would otherwise be merged again on each level.
294
+ const mergedSchemaKey = location.getSchemaRef() + JSON.stringify(siblingLocations.map((l) => l.schema))
295
+
296
+ let mergedSchemaId = context.mergedRefsIds.get(mergedSchemaKey)
297
+ if (mergedSchemaId === undefined) {
298
+ mergedSchemaId = `__fjs_merged_${schemaIdCounter++}`
299
+ try {
300
+ mergeLocations(context, mergedSchemaId, [location, ...siblingLocations])
301
+ } catch (err) {
302
+ if (!(err instanceof MergeError)) throw err
303
+ return location
304
+ }
305
+ context.mergedRefsIds.set(mergedSchemaKey, mergedSchemaId)
306
+ }
307
+
308
+ return getMergedLocation(context, mergedSchemaId)
258
309
  }
259
310
 
260
311
  function getSchemaDependencies (refResolver, schemaId) {
@@ -375,6 +426,7 @@ function build (schema, options) {
375
426
  validatorSchemasIds: new Set(),
376
427
  validatorSchemaRefs: new Set(),
377
428
  mergedSchemasIds: new Map(),
429
+ mergedRefsIds: new Map(),
378
430
  maxDepth,
379
431
  recursiveSchemas: new Set(),
380
432
  recursivePaths: new Set(),
@@ -1055,7 +1107,10 @@ function buildArray (context, location, input) {
1055
1107
  // Copying small arrays costs more than the existing loop.
1056
1108
  if (typeof arrayLength === 'number' && arrayLength >= ${fastPath.minLength} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
1057
1109
  const copy = new Array(arrayLength)
1058
- Object.setPrototypeOf(copy, null)
1110
+ // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires
1111
+ // the standard Array.prototype; a null prototype halves its throughput.
1112
+ // Only detach the prototype when a toJSON hook would otherwise be called.
1113
+ if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null)
1059
1114
  for (let i = 0; i < arrayLength; i++) {
1060
1115
  const v = obj[i]
1061
1116
  if (!(${fastPath.condition})) {
package/lib/serializer.js CHANGED
@@ -140,7 +140,10 @@ module.exports = class Serializer {
140
140
  // matching buildSingleTypeSerializer without rereading getters.
141
141
  asPlainStringArray (arr, len = arr.length) {
142
142
  const copy = new Array(len)
143
- Object.setPrototypeOf(copy, null)
143
+ // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires the
144
+ // standard Array.prototype; a null prototype halves its throughput. Only
145
+ // detach the prototype when a toJSON hook would otherwise be called.
146
+ if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null)
144
147
  for (let i = 0; i < len; i++) {
145
148
  const value = arr[i]
146
149
  if (typeof value !== 'string') {
package/lib/validator.js CHANGED
@@ -15,7 +15,7 @@ class Validator {
15
15
  this.ajv = new Ajv({
16
16
  ...ajvOptions,
17
17
  ...codeOptions,
18
- strictSchema: false,
18
+ strictSchema: ajvOptions?.strictSchema ?? false,
19
19
  validateSchema: false,
20
20
  allowUnionTypes: true,
21
21
  uriResolver: fastUri
@@ -50,7 +50,7 @@ class Validator {
50
50
  const ajvSchema = clone(schema)
51
51
  this.convertSchemaToAjvFormat(ajvSchema)
52
52
  this.ajv.addSchema(ajvSchema, schemaKey)
53
- this._ajvSchemas[schemaKey] = schema
53
+ this._ajvSchemas[schemaKey] = ajvSchema
54
54
  }
55
55
  }
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.1.2",
3
+ "version": "7.1.4",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const build = require('..')
5
+
6
+ // The copy-then-JSON.stringify fast path for primitive arrays must produce the
7
+ // same output whether or not Array.prototype.toJSON has been patched, and must
8
+ // never invoke such a hook.
9
+
10
+ const numbers = Array.from({ length: 1000 }, (_, i) => i * 1.37)
11
+ const integers = Array.from({ length: 1000 }, (_, i) => i)
12
+ const strings = Array.from({ length: 1000 }, (_, i) => 'item-' + i)
13
+
14
+ const cases = [
15
+ ['number', { type: 'array', items: { type: 'number' } }, numbers, {}],
16
+ ['integer', { type: 'array', items: { type: 'integer' } }, integers, {}],
17
+ ['string', { type: 'array', items: { type: 'string' } }, strings, { largeArraySize: 100 }]
18
+ ]
19
+
20
+ for (const [name, schema, data, options] of cases) {
21
+ test(`${name} array fast path with pristine Array.prototype`, t => {
22
+ t.plan(2)
23
+ t.assert.equal(Array.prototype.toJSON, undefined)
24
+ const stringify = build(schema, options)
25
+ t.assert.equal(stringify(data), JSON.stringify(data))
26
+ })
27
+
28
+ test(`${name} array fast path ignores a patched Array.prototype.toJSON`, t => {
29
+ t.plan(2)
30
+ const expected = JSON.stringify(data)
31
+ let called = 0
32
+ // eslint-disable-next-line no-extend-native
33
+ Array.prototype.toJSON = function () { called++; return 'hooked' }
34
+ t.after(() => { delete Array.prototype.toJSON })
35
+ const stringify = build(schema, options)
36
+ t.assert.equal(stringify(data), expected)
37
+ t.assert.equal(called, 0)
38
+ })
39
+
40
+ test(`${name} array fast path resumes per-item coercion after a miss, with patched toJSON`, t => {
41
+ t.plan(1)
42
+ const mixed = data.slice()
43
+ mixed[500] = name === 'string' ? 42 : '7'
44
+ const stringify = build(schema, options)
45
+ const expected = stringify(mixed)
46
+ // eslint-disable-next-line no-extend-native
47
+ Array.prototype.toJSON = function () { return 'hooked' }
48
+ t.after(() => { delete Array.prototype.toJSON })
49
+ t.assert.equal(build(schema, options)(mixed), expected)
50
+ })
51
+ }
package/test/ref.test.js CHANGED
@@ -2075,3 +2075,230 @@ test('ref nested', (t) => {
2075
2075
  t.assert.doesNotThrow(() => JSON.parse(output))
2076
2076
  t.assert.equal(output, '{"str":"test"}')
2077
2077
  })
2078
+
2079
+ test('ref internal - sibling keywords', (t) => {
2080
+ t.plan(3)
2081
+
2082
+ const schema = {
2083
+ definitions: {
2084
+ def: {
2085
+ type: 'object',
2086
+ properties: {
2087
+ str: {
2088
+ type: 'string'
2089
+ },
2090
+ num: {
2091
+ type: 'integer'
2092
+ }
2093
+ },
2094
+ required: ['str']
2095
+ }
2096
+ },
2097
+ type: 'object',
2098
+ properties: {
2099
+ obj: {
2100
+ $ref: '#/definitions/def',
2101
+ required: ['num']
2102
+ }
2103
+ }
2104
+ }
2105
+
2106
+ const object = {
2107
+ obj: {
2108
+ str: 'test',
2109
+ num: 42
2110
+ }
2111
+ }
2112
+
2113
+ const stringify = build(schema)
2114
+ const output = stringify(object)
2115
+
2116
+ t.assert.doesNotThrow(() => JSON.parse(output))
2117
+ t.assert.equal(output, '{"obj":{"str":"test","num":42}}')
2118
+
2119
+ t.assert.throws(() => {
2120
+ stringify({
2121
+ obj: {
2122
+ str: 'test'
2123
+ }
2124
+ })
2125
+ }, { message: '"num" is required!' })
2126
+ })
2127
+
2128
+ test('ref external - sibling keywords', (t) => {
2129
+ t.plan(3)
2130
+
2131
+ const externalSchema = {
2132
+ external: {
2133
+ definitions: {
2134
+ def: {
2135
+ type: 'object',
2136
+ properties: {
2137
+ str: {
2138
+ type: 'string'
2139
+ },
2140
+ num: {
2141
+ type: 'integer'
2142
+ }
2143
+ },
2144
+ required: ['str']
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ const schema = {
2151
+ type: 'object',
2152
+ properties: {
2153
+ obj: {
2154
+ $ref: 'external#/definitions/def',
2155
+ required: ['num']
2156
+ }
2157
+ }
2158
+ }
2159
+
2160
+ const object = {
2161
+ obj: {
2162
+ str: 'test',
2163
+ num: 42
2164
+ }
2165
+ }
2166
+
2167
+ const stringify = build(schema, { schema: externalSchema })
2168
+ const output = stringify(object)
2169
+
2170
+ t.assert.doesNotThrow(() => JSON.parse(output))
2171
+ t.assert.equal(output, '{"obj":{"str":"test","num":42}}')
2172
+
2173
+ t.assert.throws(() => {
2174
+ stringify({
2175
+ obj: {
2176
+ str: 'test'
2177
+ }
2178
+ })
2179
+ }, { message: '"num" is required!' })
2180
+ })
2181
+
2182
+ test('ref external - recursive sibling keywords', (t) => {
2183
+ t.plan(3)
2184
+
2185
+ const externalSchema = {
2186
+ node: {
2187
+ $id: 'node',
2188
+ type: 'object',
2189
+ properties: {
2190
+ str: {
2191
+ type: 'string'
2192
+ },
2193
+ next: {
2194
+ $ref: 'node#',
2195
+ required: ['str']
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ const schema = {
2202
+ type: 'object',
2203
+ properties: {
2204
+ root: {
2205
+ $ref: 'node#',
2206
+ required: ['str']
2207
+ }
2208
+ }
2209
+ }
2210
+
2211
+ const object = {
2212
+ root: {
2213
+ str: 'test',
2214
+ next: {
2215
+ str: 'nested'
2216
+ }
2217
+ }
2218
+ }
2219
+
2220
+ const stringify = build(schema, { schema: externalSchema })
2221
+ const output = stringify(object)
2222
+
2223
+ t.assert.doesNotThrow(() => JSON.parse(output))
2224
+ t.assert.equal(output, '{"root":{"str":"test","next":{"str":"nested"}}}')
2225
+
2226
+ t.assert.throws(() => {
2227
+ stringify({
2228
+ root: {
2229
+ str: 'test',
2230
+ next: {}
2231
+ }
2232
+ })
2233
+ }, { message: '"str" is required!' })
2234
+ })
2235
+
2236
+ test('ref internal - conflicting sibling keywords', (t) => {
2237
+ t.plan(2)
2238
+
2239
+ const schema = {
2240
+ definitions: {
2241
+ def: {
2242
+ type: 'string'
2243
+ }
2244
+ },
2245
+ type: 'object',
2246
+ properties: {
2247
+ value: {
2248
+ $ref: '#/definitions/def',
2249
+ type: 'integer'
2250
+ }
2251
+ }
2252
+ }
2253
+
2254
+ const object = {
2255
+ value: 42
2256
+ }
2257
+
2258
+ const stringify = build(schema)
2259
+ const output = stringify(object)
2260
+
2261
+ t.assert.doesNotThrow(() => JSON.parse(output))
2262
+ t.assert.equal(output, '{"value":"42"}')
2263
+ })
2264
+
2265
+ test('ref external - sibling keywords with a duplicated anchor', (t) => {
2266
+ t.plan(1)
2267
+
2268
+ const externalSchema = {
2269
+ external: {
2270
+ $id: 'external',
2271
+ definitions: {
2272
+ def: {
2273
+ type: 'object',
2274
+ properties: {
2275
+ str: {
2276
+ $id: '#anchor',
2277
+ type: 'string'
2278
+ }
2279
+ }
2280
+ }
2281
+ }
2282
+ }
2283
+ }
2284
+
2285
+ const schema = {
2286
+ type: 'object',
2287
+ properties: {
2288
+ obj: {
2289
+ $ref: 'external#/definitions/def',
2290
+ properties: {
2291
+ num: {
2292
+ $id: '#anchor',
2293
+ type: 'integer'
2294
+ }
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2299
+
2300
+ t.assert.throws(
2301
+ () => build(schema, { schema: externalSchema }),
2302
+ { message: /There is already another anchor "#anchor"/ }
2303
+ )
2304
+ })
@@ -318,6 +318,38 @@ test('inline validators with external oneOf refs and toJSON values', async (t) =
318
318
  t.assert.throws(() => stringify({ value: true }), /does not match schema definition/)
319
319
  })
320
320
 
321
+ test('restored validator handles toJSON values', async (t) => {
322
+ t.plan(3)
323
+
324
+ after(async () => {
325
+ await fs.promises.rm(destination, { force: true })
326
+ })
327
+
328
+ const code = fjs({
329
+ type: 'object',
330
+ properties: {
331
+ value: {
332
+ oneOf: [
333
+ { type: 'string', format: 'date-time' },
334
+ { type: 'integer' }
335
+ ]
336
+ }
337
+ }
338
+ }, { mode: 'standalone', inlineValidators: false })
339
+
340
+ const destination = path.resolve(tmpDir, 'standalone-restored-toJSON.js')
341
+
342
+ await fs.promises.writeFile(destination, code)
343
+ const stringify = require(destination)
344
+
345
+ t.assert.equal(
346
+ stringify({ value: new Date('2020-01-02T03:04:05.000Z') }),
347
+ '{"value":"2020-01-02T03:04:05.000Z"}'
348
+ )
349
+ t.assert.equal(stringify({ value: 42 }), '{"value":42}')
350
+ t.assert.throws(() => stringify({ value: true }), /does not match schema definition/)
351
+ })
352
+
321
353
  test('does not emit unused inline validators', (t) => {
322
354
  const code = build({ mode: 'standalone', inlineValidators: true })
323
355
 
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const build = require('..')
5
+
6
+ function schemaWithTypo () {
7
+ return { anyOf: [{ type: 'number', mininum: 10 }] }
8
+ }
9
+
10
+ for (const compileValidators of [false, true]) {
11
+ const mode = compileValidators ? 'eager' : 'lazy'
12
+
13
+ for (const [name, ajv] of [
14
+ ['omitted', undefined],
15
+ ['empty', {}],
16
+ ['undefined', { strictSchema: undefined }],
17
+ ['null', { strictSchema: null }],
18
+ ['false', { strictSchema: false }],
19
+ ['strict true', { strict: true }],
20
+ ['explicit false with strict true', { strict: true, strictSchema: false }]
21
+ ]) {
22
+ test(`strictSchema keeps permissive behavior for ${name} (${mode})`, t => {
23
+ const stringify = build(schemaWithTypo(), { ajv, compileValidators })
24
+ t.assert.equal(stringify(1), '1')
25
+ })
26
+ }
27
+
28
+ test(`strictSchema true rejects unknown keywords (${mode})`, t => {
29
+ const options = { ajv: { strictSchema: true }, compileValidators }
30
+ const unknownKeyword = /unknown keyword: "mininum"/
31
+
32
+ if (compileValidators) {
33
+ t.assert.throws(() => build(schemaWithTypo(), options), unknownKeyword)
34
+ } else {
35
+ const stringify = build(schemaWithTypo(), options)
36
+ t.assert.throws(() => stringify(1), unknownKeyword)
37
+ }
38
+ })
39
+
40
+ test(`strictSchema log warns about unknown keywords (${mode})`, t => {
41
+ const warnings = []
42
+ const logger = {
43
+ log () {},
44
+ error () {},
45
+ warn (message) { warnings.push(message) }
46
+ }
47
+ const stringify = build(schemaWithTypo(), {
48
+ ajv: { strictSchema: 'log', logger },
49
+ compileValidators
50
+ })
51
+
52
+ t.assert.equal(stringify(1), '1')
53
+ t.assert.ok(warnings.some(message => message.includes('unknown keyword: "mininum"')))
54
+ })
55
+ }