fast-json-stringify 7.0.1 → 7.1.1

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.
Files changed (40) hide show
  1. package/.github/dependabot.yml +1 -0
  2. package/.github/workflows/ci.yml +1 -1
  3. package/.github/workflows/lock-threads.yml +1 -1
  4. package/.github/workflows/release.yml +24 -0
  5. package/README.md +20 -3
  6. package/benchmark/bench-cmp-lib.js +63 -38
  7. package/benchmark/bench-thread.js +8 -2
  8. package/benchmark/bench.js +86 -0
  9. package/benchmark/fixtures/api-responses.js +266 -0
  10. package/index.js +366 -98
  11. package/lib/location.js +3 -1
  12. package/lib/serializer.js +41 -37
  13. package/lib/standalone.js +22 -3
  14. package/lib/validator.js +22 -3
  15. package/package.json +4 -5
  16. package/test/additionalProperties.test.js +40 -0
  17. package/test/anyof.test.js +42 -0
  18. package/test/array.test.js +26 -1
  19. package/test/basic.test.js +1 -1
  20. package/test/code-generation-fallbacks.test.js +158 -0
  21. package/test/compile-validators.test.js +110 -0
  22. package/test/const.test.js +7 -6
  23. package/test/date.test.js +28 -1
  24. package/test/debug-mode.test.js +8 -0
  25. package/test/deopt.test.js +251 -0
  26. package/test/if-then-else.test.js +145 -0
  27. package/test/inferType.test.js +1 -1
  28. package/test/integer.test.js +1 -1
  29. package/test/oneof.test.js +242 -0
  30. package/test/optional-property-prefix.test.js +119 -0
  31. package/test/property-names.test.js +41 -0
  32. package/test/regex.test.js +1 -1
  33. package/test/schema-depth.test.js +227 -0
  34. package/test/standalone-mode.test.js +109 -2
  35. package/test/string.test.js +8 -0
  36. package/test/surrogate.test.js +1 -1
  37. package/test/typesArray.test.js +59 -0
  38. package/test/utils/validator.js +20 -0
  39. package/types/index.d.ts +20 -0
  40. package/types/index.tst.ts +13 -0
package/index.js CHANGED
@@ -10,8 +10,31 @@ const Location = require('./lib/location')
10
10
  const validate = require('./lib/schema-validator')
11
11
  const mergeSchemas = require('./lib/merge-schemas')
12
12
 
13
- let largeArraySize = 2e4
14
- let largeArrayMechanism = 'default'
13
+ const DEFAULT_MAX_DEPTH = 100
14
+ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
15
+
16
+ const singleSchemaKeywords = [
17
+ 'additionalItems',
18
+ 'additionalProperties',
19
+ 'contains',
20
+ 'else',
21
+ 'if',
22
+ 'not',
23
+ 'propertyNames',
24
+ 'then'
25
+ ]
26
+
27
+ const arraySchemaKeywords = [
28
+ 'allOf',
29
+ 'anyOf',
30
+ 'oneOf'
31
+ ]
32
+
33
+ const objectSchemaKeywords = [
34
+ 'definitions',
35
+ 'patternProperties',
36
+ 'properties'
37
+ ]
15
38
 
16
39
  const serializerFns = `
17
40
  const {
@@ -42,6 +65,113 @@ const validLargeArrayMechanisms = new Set([
42
65
 
43
66
  let schemaIdCounter = 0
44
67
 
68
+ function getMaxDepth (options) {
69
+ const maxDepth = options.maxDepth === undefined ? DEFAULT_MAX_DEPTH : options.maxDepth
70
+ if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_MAX_DEPTH) {
71
+ throw new Error(`Unsupported max schema depth ${maxDepth}. Expected an integer between 0 and ${DEFAULT_MAX_DEPTH}.`)
72
+ }
73
+ return maxDepth
74
+ }
75
+
76
+ function schemaDepthError (maxDepth, name) {
77
+ const prefix = name ? `"${name}" ` : ''
78
+ return new Error(`${prefix}schema exceeds maximum depth of ${maxDepth}`)
79
+ }
80
+
81
+ function assertObjectDepth (schema, maxDepth, name) {
82
+ const stack = [{ value: schema, depth: 0, exit: false }]
83
+ const path = new WeakSet()
84
+
85
+ while (stack.length > 0) {
86
+ const entry = stack.pop()
87
+ const value = entry.value
88
+
89
+ if (typeof value !== 'object' || value === null) continue
90
+
91
+ // Schema maps and arrays add at most one container between schema nodes.
92
+ // Keep dependency traversals within the same stack-safety envelope while
93
+ // still allowing maxDepth schema nodes nested through `properties`.
94
+ if (entry.depth > maxDepth * 2) {
95
+ throw schemaDepthError(maxDepth, name)
96
+ }
97
+
98
+ if (entry.exit) {
99
+ path.delete(value)
100
+ continue
101
+ }
102
+
103
+ if (path.has(value)) {
104
+ throw new Error('schema contains a circular object reference; use $ref instead')
105
+ }
106
+ path.add(value)
107
+ stack.push({ value, depth: entry.depth, exit: true })
108
+
109
+ for (const key in value) {
110
+ stack.push({ value: value[key], depth: entry.depth + 1, exit: false })
111
+ }
112
+ }
113
+ }
114
+
115
+ function assertSchemaDepth (schema, maxDepth, name) {
116
+ const stack = [{ schema, depth: 0 }]
117
+
118
+ while (stack.length > 0) {
119
+ const entry = stack.pop()
120
+ const currentSchema = entry.schema
121
+
122
+ if (entry.depth > maxDepth) {
123
+ throw schemaDepthError(maxDepth, name)
124
+ }
125
+
126
+ if (typeof currentSchema !== 'object' || currentSchema === null) continue
127
+
128
+ const nestedDepth = entry.depth + 1
129
+
130
+ for (const keyword of singleSchemaKeywords) {
131
+ if (typeof currentSchema[keyword] === 'object' && currentSchema[keyword] !== null) {
132
+ stack.push({ schema: currentSchema[keyword], depth: nestedDepth })
133
+ }
134
+ }
135
+
136
+ for (const keyword of arraySchemaKeywords) {
137
+ const schemas = currentSchema[keyword]
138
+ if (Array.isArray(schemas)) {
139
+ for (const nestedSchema of schemas) {
140
+ stack.push({ schema: nestedSchema, depth: nestedDepth })
141
+ }
142
+ }
143
+ }
144
+
145
+ const items = currentSchema.items
146
+ if (Array.isArray(items)) {
147
+ for (const nestedSchema of items) {
148
+ stack.push({ schema: nestedSchema, depth: nestedDepth })
149
+ }
150
+ } else if (typeof items === 'object' && items !== null) {
151
+ stack.push({ schema: items, depth: nestedDepth })
152
+ }
153
+
154
+ for (const keyword of objectSchemaKeywords) {
155
+ const schemas = currentSchema[keyword]
156
+ if (typeof schemas === 'object' && schemas !== null) {
157
+ for (const key of Object.keys(schemas)) {
158
+ stack.push({ schema: schemas[key], depth: nestedDepth })
159
+ }
160
+ }
161
+ }
162
+
163
+ const dependencies = currentSchema.dependencies
164
+ if (typeof dependencies === 'object' && dependencies !== null) {
165
+ for (const key of Object.keys(dependencies)) {
166
+ const dependency = dependencies[key]
167
+ if (!Array.isArray(dependency)) {
168
+ stack.push({ schema: dependency, depth: nestedDepth })
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+
45
175
  function isValidSchema (schema, name) {
46
176
  if (!validate(schema)) {
47
177
  if (name) {
@@ -57,27 +187,64 @@ function isValidSchema (schema, name) {
57
187
  }
58
188
 
59
189
  function resolveRef (context, location) {
60
- const ref = location.schema.$ref
190
+ const seen = new Set()
191
+ let depth = 0
61
192
 
62
- let hashIndex = ref.indexOf('#')
63
- if (hashIndex === -1) {
64
- hashIndex = ref.length
65
- }
193
+ while (location.schema.$ref !== undefined) {
194
+ const ref = location.schema.$ref
195
+ const locationRef = location.getSchemaRef()
66
196
 
67
- const schemaId = ref.slice(0, hashIndex) || location.schemaId
68
- const jsonPointer = ref.slice(hashIndex) || '#'
197
+ if (seen.has(locationRef)) {
198
+ throw new Error(`Cannot resolve circular reference "${ref}"`)
199
+ }
200
+ seen.add(locationRef)
201
+
202
+ if (depth++ > context.maxDepth) {
203
+ throw schemaDepthError(context.maxDepth)
204
+ }
205
+
206
+ let hashIndex = ref.indexOf('#')
207
+ if (hashIndex === -1) {
208
+ hashIndex = ref.length
209
+ }
210
+
211
+ const schemaId = ref.slice(0, hashIndex) || location.schemaId
212
+ const jsonPointer = ref.slice(hashIndex) || '#'
213
+
214
+ const schema = context.refResolver.getSchema(schemaId, jsonPointer)
215
+ if (schema === null) {
216
+ throw new Error(`Cannot find reference "${ref}"`)
217
+ }
69
218
 
70
- const schema = context.refResolver.getSchema(schemaId, jsonPointer)
71
- if (schema === null) {
72
- throw new Error(`Cannot find reference "${ref}"`)
219
+ location = new Location(schema, schemaId, jsonPointer)
73
220
  }
74
221
 
75
- const newLocation = new Location(schema, schemaId, jsonPointer)
76
- if (schema.$ref !== undefined) {
77
- return resolveRef(context, newLocation)
222
+ return location
223
+ }
224
+
225
+ function getSchemaDependencies (refResolver, schemaId) {
226
+ const dependencies = {}
227
+ const processed = new Set()
228
+ const pending = [schemaId]
229
+
230
+ while (pending.length > 0) {
231
+ const currentSchemaId = pending.pop()
232
+ if (processed.has(currentSchemaId)) continue
233
+ processed.add(currentSchemaId)
234
+
235
+ for (const ref of refResolver.getSchemaRefs(currentSchemaId)) {
236
+ const dependencySchemaId = ref.schemaId
237
+ if (
238
+ dependencySchemaId === currentSchemaId ||
239
+ dependencies[dependencySchemaId] !== undefined
240
+ ) continue
241
+
242
+ dependencies[dependencySchemaId] = refResolver.getSchema(dependencySchemaId)
243
+ pending.push(dependencySchemaId)
244
+ }
78
245
  }
79
246
 
80
- return newLocation
247
+ return dependencies
81
248
  }
82
249
 
83
250
  function getMergedLocation (context, mergedSchemaId) {
@@ -92,6 +259,14 @@ function getSchemaId (schema, rootSchemaId) {
92
259
  return rootSchemaId
93
260
  }
94
261
 
262
+ // a schema ref on a comment line of the generated code: a line terminator in a property name
263
+ // would end the comment and turn the rest of the name into code. JSON.stringify covers \n and
264
+ // \r, the two separators JS also treats as line terminators are escaped by hand
265
+ const LINE_SEPARATORS = /[\u2028\u2029]/g
266
+ function asComment (schemaRef) {
267
+ return JSON.stringify(schemaRef).replace(LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16))
268
+ }
269
+
95
270
  function getSafeSchemaRef (context, location) {
96
271
  let schemaRef = location.getSchemaRef() || ''
97
272
  if (schemaRef.startsWith(context.rootSchemaId)) {
@@ -100,11 +275,59 @@ function getSafeSchemaRef (context, location) {
100
275
  return schemaRef
101
276
  }
102
277
 
103
- function build (schema, options) {
104
- isValidSchema(schema)
278
+ function getValidatorSchemaRef (context, location) {
279
+ let schemaRef = location.getSchemaRef()
280
+ const schema = location.schema
105
281
 
282
+ // A schema that is only a registered $ref can validate against that target
283
+ // instead of a JSON-pointer path through the root schema.
284
+ if (
285
+ typeof schema.$ref !== 'string' ||
286
+ Object.keys(schema).length !== 1
287
+ ) {
288
+ return schemaRef
289
+ }
290
+
291
+ const ref = schema.$ref
292
+ const hashIndex = ref.indexOf('#')
293
+ const refSchemaId = hashIndex === -1 ? ref : ref.slice(0, hashIndex)
294
+ const refJsonPointer = hashIndex === -1 ? '' : ref.slice(hashIndex)
295
+ const isBareRef = refJsonPointer === '' || refJsonPointer === '#'
296
+ const isNamedFragmentRef = NAMED_FRAGMENT_REF.test(refJsonPointer)
297
+ const isJsonPointerRef = refJsonPointer.startsWith('#/')
298
+
299
+ if (
300
+ (isBareRef || isNamedFragmentRef || isJsonPointerRef) &&
301
+ refSchemaId &&
302
+ context.refResolver.hasSchema(refSchemaId)
303
+ ) {
304
+ const refSchema = context.refResolver.getSchema(refSchemaId)
305
+ const refSubSchema = isBareRef
306
+ ? refSchema
307
+ : context.refResolver.getSchema(refSchemaId, refJsonPointer)
308
+ const hasRelativeSchemaId = (
309
+ typeof refSchema.$id === 'string' &&
310
+ refSchema.$id.charAt(0) === '#'
311
+ )
312
+ if (
313
+ refSubSchema !== null &&
314
+ (!hasRelativeSchemaId || refSchema.$id === refJsonPointer)
315
+ ) {
316
+ schemaRef = refSchemaId + (isBareRef ? '#' : refJsonPointer)
317
+ }
318
+ }
319
+
320
+ return schemaRef
321
+ }
322
+
323
+ function build (schema, options) {
106
324
  options = options || {}
107
325
 
326
+ const maxDepth = getMaxDepth(options)
327
+ assertObjectDepth(schema, maxDepth)
328
+ assertSchemaDepth(schema, maxDepth)
329
+ isValidSchema(schema)
330
+
108
331
  const context = {
109
332
  functions: [],
110
333
  functionsCounter: 0,
@@ -113,10 +336,14 @@ function build (schema, options) {
113
336
  refResolver: new RefResolver(),
114
337
  rootSchemaId: schema.$id || `__fjs_root_${schemaIdCounter++}`,
115
338
  validatorSchemasIds: new Set(),
339
+ validatorSchemaRefs: new Set(),
116
340
  mergedSchemasIds: new Map(),
341
+ maxDepth,
117
342
  recursiveSchemas: new Set(),
118
343
  recursivePaths: new Set(),
119
344
  buildingSet: new Set(),
345
+ largeArraySize: 2e4,
346
+ largeArrayMechanism: 'default',
120
347
  uid: 0
121
348
  }
122
349
 
@@ -130,6 +357,8 @@ function build (schema, options) {
130
357
  const schema = options.schema[key]
131
358
  const schemaId = getSchemaId(schema, key)
132
359
  if (!context.refResolver.hasSchema(schemaId)) {
360
+ assertObjectDepth(schema, maxDepth, key)
361
+ assertSchemaDepth(schema, maxDepth, key)
133
362
  isValidSchema(schema, key)
134
363
  context.refResolver.addSchema(schema, key)
135
364
  }
@@ -144,7 +373,7 @@ function build (schema, options) {
144
373
 
145
374
  if (options.largeArrayMechanism) {
146
375
  if (validLargeArrayMechanisms.has(options.largeArrayMechanism)) {
147
- largeArrayMechanism = options.largeArrayMechanism
376
+ context.largeArrayMechanism = options.largeArrayMechanism
148
377
  } else {
149
378
  throw new Error(`Unsupported large array mechanism ${options.largeArrayMechanism}`)
150
379
  }
@@ -155,11 +384,11 @@ function build (schema, options) {
155
384
  let parsedNumber
156
385
 
157
386
  if (largeArraySizeType === 'string' && Number.isFinite((parsedNumber = Number.parseInt(options.largeArraySize, 10)))) {
158
- largeArraySize = parsedNumber
387
+ context.largeArraySize = parsedNumber
159
388
  } else if (largeArraySizeType === 'number' && Number.isInteger(options.largeArraySize)) {
160
- largeArraySize = options.largeArraySize
389
+ context.largeArraySize = options.largeArraySize
161
390
  } else if (largeArraySizeType === 'bigint') {
162
- largeArraySize = Number(options.largeArraySize)
391
+ context.largeArraySize = Number(options.largeArraySize)
163
392
  } else {
164
393
  throw new Error(`Unsupported large array size. Expected integer-like, got ${typeof options.largeArraySize} with value ${options.largeArraySize}`)
165
394
  }
@@ -208,18 +437,25 @@ function build (schema, options) {
208
437
  }
209
438
 
210
439
  const serializer = new Serializer(options)
211
- const validator = new Validator(options.ajv)
440
+ const validator = new Validator(
441
+ options.ajv,
442
+ options.mode === 'standalone' && options.inlineValidators
443
+ )
212
444
 
213
445
  for (const schemaId of context.validatorSchemasIds) {
214
446
  const schema = context.refResolver.getSchema(schemaId)
215
447
  validator.addSchema(schema, schemaId)
216
448
 
217
- const dependencies = context.refResolver.getSchemaDependencies(schemaId)
449
+ const dependencies = getSchemaDependencies(context.refResolver, schemaId)
218
450
  for (const [schemaId, schema] of Object.entries(dependencies)) {
219
451
  validator.addSchema(schema, schemaId)
220
452
  }
221
453
  }
222
454
 
455
+ if (options.compileValidators) {
456
+ validator.compileSchemas(context.validatorSchemaRefs)
457
+ }
458
+
223
459
  if (options.mode === 'debug') {
224
460
  return {
225
461
  validator,
@@ -329,7 +565,12 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV
329
565
  const additionalPropertiesLocation = location.getPropertyLocation('additionalProperties')
330
566
  const additionalPropertiesSchema = additionalPropertiesLocation.schema
331
567
 
332
- if (additionalPropertiesSchema !== undefined) {
568
+ // `additionalProperties: false` means every property that is not declared in
569
+ // `properties` nor matched by `patternProperties` is dropped, so no branch is
570
+ // emitted for it. Without this guard the `false` schema reaches buildValue,
571
+ // which serializes any boolean schema with `JSON.stringify(value)` and lets
572
+ // the property through.
573
+ if (additionalPropertiesSchema !== undefined && additionalPropertiesSchema !== false) {
333
574
  if (additionalPropertiesSchema === true) {
334
575
  code += `
335
576
  ${addComma}
@@ -447,12 +688,17 @@ function buildInnerObject (context, location, objVar) {
447
688
  const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
448
689
  const defaultValue = propertyLocation.schema.default
449
690
  const isRequired = requiredProperties.includes(key) // Should be false here but good to keep
691
+ // Select a complete prefix so the comma does not need a separate concatenation.
692
+ const propertyPrefix = sanitizedKey + ':'
693
+ const addProperty = needsRuntimeComma
694
+ ? `json += addComma_${localUid} ? ${JSON.stringify(',' + propertyPrefix)} : ${JSON.stringify(propertyPrefix)}
695
+ addComma_${localUid} = true`
696
+ : `json += ${JSON.stringify(propertyPrefix)}`
450
697
 
451
698
  code += `
452
699
  const ${value} = ${objVar}[${sanitizedKey}]
453
700
  if (${value} !== undefined) {
454
- ${addComma}
455
- json += ${JSON.stringify(sanitizedKey + ':')}
701
+ ${addProperty}
456
702
  ${buildValue(context, propertyLocation, `${value}`)}
457
703
  }`
458
704
 
@@ -568,7 +814,7 @@ function buildObject (context, location, input) {
568
814
  const schemaRef = getSafeSchemaRef(context, location)
569
815
 
570
816
  const functionCode = `
571
- // ${schemaRef}
817
+ // ${asComment(schemaRef)}
572
818
  function ${functionName} (input) {
573
819
  const obj = ${toJSON('input')}
574
820
  if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
@@ -628,14 +874,14 @@ function buildArray (context, location, input) {
628
874
 
629
875
  let functionCode = `
630
876
  function ${functionName} (obj) {
631
- // ${schemaRef}
877
+ // ${asComment(schemaRef)}
632
878
  let json = ''
633
879
  `
634
880
 
635
881
  functionCode += `
636
882
  if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'}
637
883
  if (!Array.isArray(obj)) {
638
- throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`)
884
+ throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)})
639
885
  }
640
886
  const arrayLength = obj.length
641
887
  `
@@ -648,8 +894,8 @@ function buildArray (context, location, input) {
648
894
  `
649
895
  }
650
896
 
651
- if (largeArrayMechanism === 'json-stringify') {
652
- functionCode += `if (arrayLength >= ${largeArraySize}) return JSON.stringify(obj)\n`
897
+ if (context.largeArrayMechanism === 'json-stringify') {
898
+ functionCode += `if (arrayLength >= ${context.largeArraySize}) return JSON.stringify(obj)\n`
653
899
  }
654
900
 
655
901
  functionCode += `
@@ -720,7 +966,7 @@ function buildArray (context, location, input) {
720
966
  if (${objVar} === null) {
721
967
  json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'}
722
968
  } else if (!Array.isArray(${objVar})) {
723
- throw new TypeError(\`The value of '${safeSchemaRef}' does not match schema definition.\`)
969
+ throw new TypeError(${JSON.stringify(`The value of '${safeSchemaRef}' does not match schema definition.`)})
724
970
  } else {
725
971
  const arrayLength_${objVar} = ${objVar}.length
726
972
  `
@@ -733,8 +979,8 @@ function buildArray (context, location, input) {
733
979
  `
734
980
  }
735
981
 
736
- if (largeArrayMechanism === 'json-stringify') {
737
- inlinedCode += `if (arrayLength_${objVar} >= ${largeArraySize}) json += JSON.stringify(${objVar})\n else {`
982
+ if (context.largeArrayMechanism === 'json-stringify') {
983
+ inlinedCode += `if (arrayLength_${objVar} >= ${context.largeArraySize}) json += JSON.stringify(${objVar})\n else {`
738
984
  }
739
985
 
740
986
  inlinedCode += `
@@ -791,7 +1037,7 @@ function buildArray (context, location, input) {
791
1037
  json += JSON_STR_END_ARRAY
792
1038
  `
793
1039
 
794
- if (largeArrayMechanism === 'json-stringify') {
1040
+ if (context.largeArrayMechanism === 'json-stringify') {
795
1041
  inlinedCode += '}'
796
1042
  }
797
1043
 
@@ -900,6 +1146,17 @@ function buildMultiTypeSerializer (context, location, input) {
900
1146
  `
901
1147
  break
902
1148
  }
1149
+ case 'object': {
1150
+ // An array is `typeof === 'object'`, so it would otherwise be captured
1151
+ // by this branch and serialized as an object (dropping its items). Exclude
1152
+ // arrays here so a sibling `array` type in the same `type` list can match.
1153
+ code += `
1154
+ ${statement}((typeof ${input} === "object" && !Array.isArray(${input})) || ${input} === null) {
1155
+ ${nestedResult}
1156
+ }
1157
+ `
1158
+ break
1159
+ }
903
1160
  default: {
904
1161
  code += `
905
1162
  ${statement}(typeof ${input} === "${type}" || ${input} === null) {
@@ -911,7 +1168,7 @@ function buildMultiTypeSerializer (context, location, input) {
911
1168
  }
912
1169
  })
913
1170
  code += `
914
- else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`)
1171
+ else throw new TypeError(${JSON.stringify(`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.`)})
915
1172
  `
916
1173
 
917
1174
  return code
@@ -971,9 +1228,13 @@ function buildSingleTypeSerializer (context, location, input) {
971
1228
 
972
1229
  function detectRecursiveSchemas (context, location) {
973
1230
  const pathStack = new Set()
974
- function traverse (location) {
1231
+ function traverse (location, depth) {
975
1232
  const schema = location.schema
976
- if (typeof schema !== 'object' || schema === null) return
1233
+ if (!schema || typeof schema !== 'object') return
1234
+
1235
+ if (depth > context.maxDepth) {
1236
+ throw schemaDepthError(context.maxDepth)
1237
+ }
977
1238
 
978
1239
  const schemaId = location.schemaId || ''
979
1240
  const jsonPointer = location.jsonPointer || ''
@@ -994,65 +1255,66 @@ function detectRecursiveSchemas (context, location) {
994
1255
  if (schema.$ref) {
995
1256
  try {
996
1257
  const res = resolveRef(context, location)
997
- traverse(res)
1258
+ traverse(res, depth)
998
1259
  } catch (err) {
1260
+ if (!err.message.startsWith('Cannot find reference')) throw err
999
1261
  // Validation will handle missing refs later
1000
1262
  }
1001
1263
  }
1002
1264
 
1003
- if (schema.properties) {
1004
- const propertiesLocation = location.getPropertyLocation('properties')
1005
- for (const key in schema.properties) {
1006
- traverse(propertiesLocation.getPropertyLocation(key))
1007
- }
1008
- }
1009
- if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
1010
- traverse(location.getPropertyLocation('additionalProperties'))
1011
- }
1012
- if (schema.patternProperties) {
1013
- const patternPropertiesLocation = location.getPropertyLocation('patternProperties')
1014
- for (const key in schema.patternProperties) {
1015
- traverse(patternPropertiesLocation.getPropertyLocation(key))
1265
+ const nestedDepth = depth + 1
1266
+
1267
+ for (const keyword of singleSchemaKeywords) {
1268
+ if (typeof schema[keyword] === 'object' && schema[keyword] !== null) {
1269
+ traverse(location.getPropertyLocation(keyword), nestedDepth)
1016
1270
  }
1017
1271
  }
1018
- if (schema.items) {
1019
- const itemsLocation = location.getPropertyLocation('items')
1020
- if (Array.isArray(schema.items)) {
1021
- for (let i = 0; i < schema.items.length; i++) {
1022
- traverse(itemsLocation.getPropertyLocation(i))
1272
+
1273
+ for (const keyword of arraySchemaKeywords) {
1274
+ const schemas = schema[keyword]
1275
+ if (Array.isArray(schemas)) {
1276
+ const schemasLocation = location.getPropertyLocation(keyword)
1277
+ const schemasLength = schemas.length
1278
+ for (let i = 0; i < schemasLength; i++) {
1279
+ traverse(schemasLocation.getPropertyLocation(i), nestedDepth)
1023
1280
  }
1024
- } else {
1025
- traverse(itemsLocation)
1026
1281
  }
1027
1282
  }
1028
- if (schema.additionalItems && typeof schema.additionalItems === 'object') {
1029
- traverse(location.getPropertyLocation('additionalItems'))
1030
- }
1031
1283
 
1032
- if (schema.oneOf) {
1033
- const oneOfLocation = location.getPropertyLocation('oneOf')
1034
- for (let i = 0; i < schema.oneOf.length; i++) {
1035
- traverse(oneOfLocation.getPropertyLocation(i))
1284
+ const items = schema.items
1285
+ if (Array.isArray(items)) {
1286
+ const itemsLocation = location.getPropertyLocation('items')
1287
+ const itemsLength = items.length
1288
+ for (let i = 0; i < itemsLength; i++) {
1289
+ traverse(itemsLocation.getPropertyLocation(i), nestedDepth)
1036
1290
  }
1291
+ } else if (typeof items === 'object' && items !== null) {
1292
+ traverse(location.getPropertyLocation('items'), nestedDepth)
1037
1293
  }
1038
- if (schema.anyOf) {
1039
- const anyOfLocation = location.getPropertyLocation('anyOf')
1040
- for (let i = 0; i < schema.anyOf.length; i++) {
1041
- traverse(anyOfLocation.getPropertyLocation(i))
1294
+
1295
+ for (const keyword of objectSchemaKeywords) {
1296
+ const schemas = schema[keyword]
1297
+ if (typeof schemas === 'object' && schemas !== null) {
1298
+ const schemasLocation = location.getPropertyLocation(keyword)
1299
+ for (const key in schemas) {
1300
+ traverse(schemasLocation.getPropertyLocation(key), nestedDepth)
1301
+ }
1042
1302
  }
1043
1303
  }
1044
- if (schema.allOf) {
1045
- const allOfLocation = location.getPropertyLocation('allOf')
1046
- for (let i = 0; i < schema.allOf.length; i++) {
1047
- traverse(allOfLocation.getPropertyLocation(i))
1304
+
1305
+ const dependencies = schema.dependencies
1306
+ if (typeof dependencies === 'object' && dependencies !== null) {
1307
+ const dependenciesLocation = location.getPropertyLocation('dependencies')
1308
+ for (const key in dependencies) {
1309
+ if (!Array.isArray(dependencies[key])) {
1310
+ traverse(dependenciesLocation.getPropertyLocation(key), nestedDepth)
1311
+ }
1048
1312
  }
1049
1313
  }
1050
- if (schema.then) traverse(location.getPropertyLocation('then'))
1051
- if (schema.else) traverse(location.getPropertyLocation('else'))
1052
1314
 
1053
1315
  pathStack.delete(fullPath)
1054
1316
  }
1055
- traverse(location)
1317
+ traverse(location, 0)
1056
1318
  }
1057
1319
 
1058
1320
  function buildConstSerializer (location, input) {
@@ -1148,17 +1410,18 @@ function buildOneOf (context, location, input) {
1148
1410
  }
1149
1411
 
1150
1412
  const nestedResult = buildValue(context, mergedLocation, input)
1151
- const schemaRef = optionLocation.getSchemaRef()
1413
+ const schemaRef = getValidatorSchemaRef(context, optionLocation)
1414
+ context.validatorSchemaRefs.add(schemaRef)
1152
1415
 
1153
1416
  code += `
1154
- ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) {
1417
+ ${index === 0 ? 'if' : 'else if'}(validator.validate(${JSON.stringify(schemaRef)}, ${input})) {
1155
1418
  ${nestedResult}
1156
1419
  }
1157
1420
  `
1158
1421
  }
1159
1422
 
1160
1423
  code += `
1161
- else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`)
1424
+ else throw new TypeError(${JSON.stringify(`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.`)})
1162
1425
  `
1163
1426
 
1164
1427
  return code
@@ -1181,26 +1444,31 @@ function buildIfThenElse (context, location, input) {
1181
1444
  )
1182
1445
 
1183
1446
  const ifLocation = location.getPropertyLocation('if')
1184
- const ifSchemaRef = ifLocation.getSchemaRef()
1185
-
1186
- const thenLocation = location.getPropertyLocation('then')
1187
- let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema)
1188
- let thenMergedLocation = null
1189
- if (thenMergedSchemaId) {
1190
- thenMergedLocation = getMergedLocation(context, thenMergedSchemaId)
1191
- } else {
1192
- thenMergedSchemaId = `__fjs_merged_${schemaIdCounter++}`
1193
- context.mergedSchemasIds.set(thenSchema, thenMergedSchemaId)
1447
+ const ifSchemaRef = getValidatorSchemaRef(context, ifLocation)
1448
+ context.validatorSchemaRefs.add(ifSchemaRef)
1449
+
1450
+ // `then` is optional: a schema may pair `if` with `else` alone. In that case
1451
+ // the true branch adds no keywords, so it serializes with the root schema.
1452
+ let thenMergedLocation = rootLocation
1453
+ if (thenSchema !== undefined) {
1454
+ const thenLocation = location.getPropertyLocation('then')
1455
+ let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema)
1456
+ if (thenMergedSchemaId) {
1457
+ thenMergedLocation = getMergedLocation(context, thenMergedSchemaId)
1458
+ } else {
1459
+ thenMergedSchemaId = `__fjs_merged_${schemaIdCounter++}`
1460
+ context.mergedSchemasIds.set(thenSchema, thenMergedSchemaId)
1194
1461
 
1195
- thenMergedLocation = mergeLocations(context, thenMergedSchemaId, [
1196
- rootLocation,
1197
- thenLocation
1198
- ])
1462
+ thenMergedLocation = mergeLocations(context, thenMergedSchemaId, [
1463
+ rootLocation,
1464
+ thenLocation
1465
+ ])
1466
+ }
1199
1467
  }
1200
1468
 
1201
1469
  if (!elseSchema) {
1202
1470
  return `
1203
- if (validator.validate("${ifSchemaRef}", ${input})) {
1471
+ if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) {
1204
1472
  ${buildValue(context, thenMergedLocation, input)}
1205
1473
  } else {
1206
1474
  ${buildValue(context, rootLocation, input)}
@@ -1224,7 +1492,7 @@ function buildIfThenElse (context, location, input) {
1224
1492
  }
1225
1493
 
1226
1494
  return `
1227
- if (validator.validate("${ifSchemaRef}", ${input})) {
1495
+ if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) {
1228
1496
  ${buildValue(context, thenMergedLocation, input)}
1229
1497
  } else {
1230
1498
  ${buildValue(context, elseMergedLocation, input)}
@@ -1252,7 +1520,7 @@ function buildValue (context, location, input) {
1252
1520
  return buildOneOf(context, location, input)
1253
1521
  }
1254
1522
 
1255
- if (schema.if && schema.then) {
1523
+ if (schema.if && (schema.then || schema.else)) {
1256
1524
  return buildIfThenElse(context, location, input)
1257
1525
  }
1258
1526