fast-json-stringify 7.1.1 → 7.1.3

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
@@ -623,11 +623,15 @@ In order to improve that the user can set the `largeArrayMechanism` and
623
623
 
624
624
  `largeArrayMechanism`'s default value is `default`. Valid values for it are:
625
625
 
626
- - `default` - This option is a compromise between performance and feature set by
627
- still providing the expected functionality out of this lib but giving up some
628
- possible performance gain. With this option set, **large arrays** would be
629
- stringified by joining their stringified elements using `Array.join` instead of
630
- string concatenation for better performance
626
+ - `default` - This option keeps the full feature set of this lib: every element
627
+ is still coerced to the type declared in `items`, and type errors are still
628
+ thrown. Arrays whose `items` schema is a plain `{ type: 'string' }` (no
629
+ `format`, `nullable`, `const`, or combinator keyword) are serialized at or
630
+ above `largeArraySize` by copying the elements and calling the native
631
+ `JSON.stringify` on the copy, which is several times faster than appending one
632
+ element at a time. If an element is not a string, that element and the ones
633
+ after it go through the regular per-element coercion, so the output is the
634
+ same as before.
631
635
  - `json-stringify` - This option will remove support for schema validation
632
636
  within **large arrays** completely. By doing so the overhead previously
633
637
  mentioned is nulled, greatly improving execution time. Mind there's no change
@@ -642,6 +646,32 @@ integer-like values, such as:
642
646
  - `'2e4'` - _note this will be converted to `2`, not `20000`_
643
647
  - `1.5` - _note this will be converted to `1`_
644
648
 
649
+ ##### Number and integer arrays
650
+
651
+ With `largeArrayMechanism: 'default'`, arrays whose `items` schema is a plain
652
+ `{ type: 'number' }` or `{ type: 'integer' }` (no `nullable`, `const`, `$ref`,
653
+ or combinator keyword) use the same copy-then-`JSON.stringify` strategy, but
654
+ with their own length cutoffs that do not depend on `largeArraySize`. The
655
+ cutoffs are the array lengths above which the copy beats the per-element loop,
656
+ and they depend on the V8 version:
657
+
658
+ | Node.js | `number` | `integer` |
659
+ |---|---:|---:|
660
+ | 22 and later | 128 elements | 512 elements |
661
+ | 20 | 300000 elements | 300000 elements |
662
+
663
+ Below the cutoff the array is serialized element by element as before. Above
664
+ it, the elements are copied until one is found that is not already a `number`
665
+ (or not already an integer, for `integer`); that element and the ones after it
666
+ go through the regular per-element coercion and validation (`null` still
667
+ becomes `0`, `rounding` still applies, `NaN` still throws). The output is
668
+ byte-identical either way. Arrays of `boolean`, `null`, objects, or any other
669
+ `items` schema are not affected.
670
+
671
+ In [standalone mode](#standalone) the cutoffs are baked into the generated
672
+ code, so the generated module inherits the cutoffs of the Node.js version that
673
+ ran `build()`, not of the one that runs the module.
674
+
645
675
  <a name="unsafe"></a>
646
676
  #### Unsafe string
647
677
  By default, the library escapes all strings. With the 'unsafe' format, the string isn't escaped. This has a potentially dangerous security issue. You can use it only if you are sure that your data doesn't need escaping. The advantage is a significant performance improvement.
@@ -748,6 +778,12 @@ may require Ajv runtime helpers used by the generated validation code. Custom
748
778
  Ajv formats used by these schemas must support Ajv's standalone code
749
779
  generation.
750
780
 
781
+ The generated code also bakes in the length cutoffs used for
782
+ [number and integer arrays](#largearrays). Those cutoffs are chosen for the
783
+ Node.js version that ran `build()`; a module built on Node.js 20 and run on
784
+ Node.js 22 or later keeps the Node.js 20 cutoffs, and vice versa. The output is
785
+ the same either way, only the speed differs.
786
+
751
787
  <a name="acknowledgments"></a>
752
788
  ## Acknowledgments
753
789
 
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+
3
+ const arrayKinds = ['integer', 'number', 'short-string', 'long-string', 'flat-object', 'nested-object', 'nullable-number', 'Float64Array', 'Int32Array']
4
+ const arraySizes = [1e3, 2e4, 1e5, 1e6]
5
+ const mechanisms = ['default', 'json-stringify', 'native']
6
+ const flatSchema = { type: 'object', properties: { s: { type: 'string' }, n: { type: 'number' }, b: { type: 'boolean' } } }
7
+ const longString = 'a"quoted" string\\with\ncontrols '.repeat(4)
8
+
9
+ function arrayFixture (kind, size) {
10
+ let items
11
+ let value
12
+ switch (kind) {
13
+ case 'integer':
14
+ case 'Int32Array':
15
+ items = { type: 'integer' }
16
+ value = i => (i % 2001) - 1000
17
+ break
18
+ case 'number':
19
+ case 'Float64Array':
20
+ items = { type: 'number' }
21
+ value = i => ((i % 2001) - 1000) / 10
22
+ break
23
+ case 'short-string':
24
+ items = { type: 'string' }
25
+ value = i => `hello ${i % 100}`
26
+ break
27
+ case 'long-string':
28
+ items = { type: 'string' }
29
+ value = () => longString
30
+ break
31
+ case 'flat-object':
32
+ items = flatSchema
33
+ value = i => ({ s: `hello ${i % 100}`, n: i % 1000, b: i % 2 === 0 })
34
+ break
35
+ case 'nested-object':
36
+ items = { type: 'object', properties: { id: { type: 'integer' }, inner: { type: 'array', items: flatSchema } } }
37
+ value = i => ({ id: i, inner: [{ s: 'hello', n: i % 1000, b: true }, { s: 'world', n: i / 10, b: false }] })
38
+ break
39
+ case 'nullable-number':
40
+ items = { type: 'number', nullable: true }
41
+ value = i => i % 4 === 0 ? null : i / 10
42
+ break
43
+ default:
44
+ throw new Error(`Unknown array kind: ${kind}`)
45
+ }
46
+ const input = kind === 'Float64Array'
47
+ ? Float64Array.from({ length: size }, (_, i) => value(i))
48
+ : kind === 'Int32Array'
49
+ ? Int32Array.from({ length: size }, (_, i) => value(i))
50
+ : Array.from({ length: size }, (_, i) => value(i))
51
+ return { schema: { type: 'array', items }, input, typed: ArrayBuffer.isView(input) }
52
+ }
53
+
54
+ module.exports = { arrayKinds, arraySizes, mechanisms, arrayFixture }
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { parseArgs } = require('node:util')
5
+ const { isMainThread, parentPort, workerData } = require('node:worker_threads')
6
+ const { Bench } = require('tinybench')
7
+ const build = require('..')
8
+ const { arrayKinds, arraySizes, mechanisms, arrayFixture } = require('./bench-fixtures')
9
+ const { positiveNumber, runWorker, saveResults } = require('./bench-utils')
10
+
11
+ async function measure ({ kind, size, mechanism, time, iterations }) {
12
+ const { schema, input, typed } = arrayFixture(kind, size)
13
+ const stringify = mechanism === 'native' ? JSON.stringify : build(schema, { largeArrayMechanism: mechanism })
14
+ if (typed && mechanism !== 'native') {
15
+ // Rejection is existing behavior; do not time exceptions as serialization.
16
+ assert.throws(() => stringify(input), TypeError)
17
+ return { kind, size, mechanism, status: 'unsupported (TypeError)' }
18
+ }
19
+ assert.equal(stringify(input), JSON.stringify(input))
20
+ const bytes = Buffer.byteLength(stringify(input))
21
+ const bench = new Bench({ time, iterations, warmupTime: 100, warmupIterations: 2, throws: true })
22
+ bench.add(kind, () => {
23
+ Buffer.byteLength(stringify(input))
24
+ })
25
+ await bench.run()
26
+ const { latency, throughput } = bench.tasks[0].result
27
+ return {
28
+ kind,
29
+ size,
30
+ mechanism,
31
+ status: 'ok',
32
+ bytes,
33
+ ops: throughput.p50,
34
+ meanOps: throughput.mean,
35
+ medianMs: latency.p50,
36
+ rme: latency.rme,
37
+ samples: latency.samplesCount
38
+ }
39
+ }
40
+
41
+ async function main () {
42
+ const { values } = parseArgs({ options: { kind: { type: 'string' }, size: { type: 'string' }, time: { type: 'string', default: '300' }, iterations: { type: 'string', default: '8' }, json: { type: 'string' } } })
43
+ const kinds = values.kind ? [values.kind] : arrayKinds
44
+ if (kinds.some(kind => !arrayKinds.includes(kind))) throw new Error(`kind must be one of ${arrayKinds.join(', ')}`)
45
+ const sizes = values.size ? [positiveNumber(values.size, 'size', true)] : arraySizes
46
+ const config = { time: positiveNumber(values.time, 'time'), iterations: positiveNumber(values.iterations, 'iterations', true) }
47
+ const rows = []
48
+ console.log('| Kind | Size | Mechanism | Median ops/sec | Latency RME % | Samples |')
49
+ console.log('| --- | ---: | --- | ---: | ---: | ---: |')
50
+ for (const size of sizes) {
51
+ for (const kind of kinds) {
52
+ for (const mechanism of mechanisms) {
53
+ const row = await runWorker(__filename, { kind, size, mechanism, ...config })
54
+ rows.push(row)
55
+ console.log(`| ${kind} | ${size} | ${mechanism} | ${row.ops?.toFixed(2) ?? row.status} | ${row.rme?.toFixed(2) ?? '—'} | ${row.samples ?? '—'} |`)
56
+ }
57
+ }
58
+ }
59
+ console.log('\nTyped arrays are rejected by FJS; native serializes indexed objects. Each measured call includes Buffer.byteLength().')
60
+ saveResults(values.json, { ...config, kinds, sizes }, rows)
61
+ }
62
+
63
+ if (isMainThread) main().catch(err => { console.error(err); process.exitCode = 1 })
64
+ else measure(workerData).then(result => parentPort.postMessage(result)).catch(err => { throw err })
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ const { Worker } = require('node:worker_threads')
4
+ const { writeFileSync } = require('node:fs')
5
+ const os = require('node:os')
6
+
7
+ function positiveNumber (value, name, integer = false) {
8
+ const result = Number(value)
9
+ if (!Number.isFinite(result) || result <= 0 || (integer && !Number.isInteger(result))) {
10
+ throw new Error(`${name} must be a positive ${integer ? 'integer' : 'number'}`)
11
+ }
12
+ return result
13
+ }
14
+
15
+ function runWorker (file, workerData) {
16
+ return new Promise((resolve, reject) => {
17
+ const worker = new Worker(file, { workerData })
18
+ let result
19
+ worker.on('message', value => { result = value })
20
+ worker.on('error', reject)
21
+ worker.on('exit', code => {
22
+ if (code !== 0 || result === undefined) reject(new Error(`Benchmark worker exited ${code} without a valid result`))
23
+ else resolve(result)
24
+ })
25
+ })
26
+ }
27
+
28
+ function saveResults (file, config, rows) {
29
+ if (!file) return
30
+ writeFileSync(file, JSON.stringify({
31
+ timestamp: new Date().toISOString(),
32
+ node: process.version,
33
+ platform: `${process.platform}/${process.arch}`,
34
+ cpu: os.cpus()[0].model,
35
+ cpus: os.cpus().length,
36
+ config,
37
+ rows
38
+ }, null, 2) + '\n')
39
+ }
40
+
41
+ module.exports = { positiveNumber, runWorker, saveResults }
package/index.js CHANGED
@@ -13,6 +13,29 @@ const mergeSchemas = require('./lib/merge-schemas')
13
13
  const DEFAULT_MAX_DEPTH = 100
14
14
  const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
15
15
 
16
+ // Minimum array lengths at which copying into a plain array and
17
+ // calling native JSON.stringify beats the generated per-item loop. Measured
18
+ // against main with tinybench (alternating rounds, median ops/s, output
19
+ // consumed with Buffer.byteLength) on Node 20, 22, 24 and 26 x64.
20
+ //
21
+ // Node 22+: floats win from 32 elements, but integer-valued elements under a
22
+ // `number` schema only break even around 100 on Node 26 (-25% at 32), and a
23
+ // `number` schema cannot tell the two apart at build time, so numbers wait
24
+ // for 128. Integers win from 512 because small-int ToString is cached, so
25
+ // the loop is already cheap below that.
26
+ //
27
+ // Node 20: JSON.stringify only beats the loop for every element kind from
28
+ // ~3e5 (1e5 is machine-dependent), so both kinds wait for 3e5 there.
29
+ //
30
+ // Booleans never win and keep the loop. The array lookup instead of a
31
+ // ternary keeps both branches covered on every Node.
32
+ // TODO: drop the Node 20 tier (and this version check) once Node 20 is no
33
+ // longer supported.
34
+ const nodeMajor = Number.parseInt(process.versions.node, 10)
35
+ const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5 }, { integer: 512, number: 128 }][+(nodeMajor >= 22)]
36
+ const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
37
+ const NUMBER_ARRAY_FAST_PATH = { condition: 'typeof v === \'number\' && v === v', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.number }
38
+
16
39
  const singleSchemaKeywords = [
17
40
  'additionalItems',
18
41
  'additionalProperties',
@@ -44,7 +67,8 @@ const {
44
67
  asDateTime,
45
68
  asDate,
46
69
  asTime,
47
- asUnsafeString
70
+ asUnsafeString,
71
+ asPlainStringArray
48
72
  } = serializer
49
73
 
50
74
  const asInteger = serializer.asInteger.bind(serializer)
@@ -81,6 +105,7 @@ function schemaDepthError (maxDepth, name) {
81
105
  function assertObjectDepth (schema, maxDepth, name) {
82
106
  const stack = [{ value: schema, depth: 0, exit: false }]
83
107
  const path = new WeakSet()
108
+ let hasRef = false
84
109
 
85
110
  while (stack.length > 0) {
86
111
  const entry = stack.pop()
@@ -107,9 +132,19 @@ function assertObjectDepth (schema, maxDepth, name) {
107
132
  stack.push({ value, depth: entry.depth, exit: true })
108
133
 
109
134
  for (const key in value) {
110
- stack.push({ value: value[key], depth: entry.depth + 1, exit: false })
135
+ const child = value[key]
136
+ if (key === '$ref' && typeof child === 'string') {
137
+ hasRef = true
138
+ }
139
+ // Primitive values cannot contain cycles or contribute object depth.
140
+ // Avoid allocating stack entries that the next iteration would discard.
141
+ if (typeof child === 'object' && child !== null) {
142
+ stack.push({ value: child, depth: entry.depth + 1, exit: false })
143
+ }
111
144
  }
112
145
  }
146
+
147
+ return hasRef
113
148
  }
114
149
 
115
150
  function assertSchemaDepth (schema, maxDepth, name) {
@@ -324,7 +359,9 @@ function build (schema, options) {
324
359
  options = options || {}
325
360
 
326
361
  const maxDepth = getMaxDepth(options)
327
- assertObjectDepth(schema, maxDepth)
362
+ // assertObjectDepth also reports whether any $ref appears; recursive-schema
363
+ // detection is only needed when $ref can form cycles.
364
+ let hasRef = assertObjectDepth(schema, maxDepth)
328
365
  assertSchemaDepth(schema, maxDepth)
329
366
  isValidSchema(schema)
330
367
 
@@ -357,7 +394,9 @@ function build (schema, options) {
357
394
  const schema = options.schema[key]
358
395
  const schemaId = getSchemaId(schema, key)
359
396
  if (!context.refResolver.hasSchema(schemaId)) {
360
- assertObjectDepth(schema, maxDepth, key)
397
+ if (assertObjectDepth(schema, maxDepth, key)) {
398
+ hasRef = true
399
+ }
361
400
  assertSchemaDepth(schema, maxDepth, key)
362
401
  isValidSchema(schema, key)
363
402
  context.refResolver.addSchema(schema, key)
@@ -395,7 +434,11 @@ function build (schema, options) {
395
434
  }
396
435
 
397
436
  const location = new Location(schema, context.rootSchemaId)
398
- detectRecursiveSchemas(context, location)
437
+ // Without $ref, json-pointer paths are unique in a tree walk, so no cycle
438
+ // can appear in recursivePaths. Skip the third full-schema traversal.
439
+ if (hasRef) {
440
+ detectRecursiveSchemas(context, location)
441
+ }
399
442
  const code = buildValue(context, location, 'input')
400
443
 
401
444
  let contextFunctionCode = `
@@ -437,23 +480,35 @@ function build (schema, options) {
437
480
  }
438
481
 
439
482
  const serializer = new Serializer(options)
440
- const validator = new Validator(
441
- options.ajv,
442
- options.mode === 'standalone' && options.inlineValidators
443
- )
444
-
445
- for (const schemaId of context.validatorSchemasIds) {
446
- const schema = context.refResolver.getSchema(schemaId)
447
- validator.addSchema(schema, schemaId)
483
+ // Ajv construction dominates tiny build() cost. Only create a Validator when
484
+ // generated code needs validator.validate (anyOf/oneOf/if-then-else), or when
485
+ // debug mode exposes the instance. Standalone already emits `validator = null`
486
+ // when validatorSchemasIds is empty.
487
+ const needsValidator =
488
+ context.validatorSchemasIds.size > 0 ||
489
+ options.mode === 'debug'
490
+
491
+ const validator = needsValidator
492
+ ? new Validator(
493
+ options.ajv,
494
+ options.mode === 'standalone' && options.inlineValidators
495
+ )
496
+ : null
448
497
 
449
- const dependencies = getSchemaDependencies(context.refResolver, schemaId)
450
- for (const [schemaId, schema] of Object.entries(dependencies)) {
498
+ if (validator !== null) {
499
+ for (const schemaId of context.validatorSchemasIds) {
500
+ const schema = context.refResolver.getSchema(schemaId)
451
501
  validator.addSchema(schema, schemaId)
502
+
503
+ const dependencies = getSchemaDependencies(context.refResolver, schemaId)
504
+ for (const [schemaId, schema] of Object.entries(dependencies)) {
505
+ validator.addSchema(schema, schemaId)
506
+ }
452
507
  }
453
- }
454
508
 
455
- if (options.compileValidators) {
456
- validator.compileSchemas(context.validatorSchemaRefs)
509
+ if (options.compileValidators) {
510
+ validator.compileSchemas(context.validatorSchemaRefs)
511
+ }
457
512
  }
458
513
 
459
514
  if (options.mode === 'debug') {
@@ -592,7 +647,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV
592
647
  return code
593
648
  }
594
649
 
595
- function buildInnerObject (context, location, objVar) {
650
+ function buildInnerObject (context, location, objVar, isReturnForm) {
596
651
  const schema = location.schema
597
652
 
598
653
  const propertiesLocation = location.getPropertyLocation('properties')
@@ -616,17 +671,16 @@ function buildInnerObject (context, location, objVar) {
616
671
  }
617
672
  }
618
673
 
619
- code += 'json += JSON_STR_BEGIN_OBJECT\n'
620
-
621
674
  const localUid = context.uid++
622
675
  let addComma = ''
676
+ let emptyObjectGuard = null
623
677
 
624
678
  // propertiesKeys is sorted required-first; the guard checks [0] is required because
625
679
  // otherwise additionalProperties/patternProperties would emit a stray `{ ,"k":v }`.
626
680
  if (propertiesKeys.length > 0 && requiredProperties.includes(propertiesKeys[0])) {
627
- // The first property is required, so we don't need a comma.
628
- // For the subsequent properties, we can blindly add a comma.
629
-
681
+ // The first property is required, so it always emits (or throws): fold the
682
+ // object-open brace into its key prefix. Subsequent properties fold the
683
+ // comma into their key prefix the same way, cutting one append per property.
630
684
  for (let i = 0; i < propertiesKeys.length; i++) {
631
685
  const key = propertiesKeys[i]
632
686
  const propertyLocation = propertiesLocation.getPropertyLocation(key)
@@ -640,23 +694,18 @@ function buildInnerObject (context, location, objVar) {
640
694
  const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
641
695
  const defaultValue = resolvedLocation.schema.default
642
696
  const isRequired = requiredProperties.includes(key)
643
-
644
- // i === 0 means it's the first property, and it IS required (due to sort). No comma.
645
- // i > 0 means it follows a required property (or a sequence starting with one). Unconditional comma.
646
- const currentAddComma = i === 0 ? '' : 'json += JSON_STR_COMMA'
697
+ const keyPrefix = (i === 0 ? '{' : ',') + sanitizedKey + ':'
647
698
 
648
699
  code += `
649
700
  const ${value} = ${objVar}[${sanitizedKey}]
650
701
  if (${value} !== undefined) {
651
- ${currentAddComma}
652
- json += ${JSON.stringify(sanitizedKey + ':')}
702
+ json += ${JSON.stringify(keyPrefix)}
653
703
  ${buildValue(context, resolvedLocation, `${value}`)}
654
704
  }`
655
705
 
656
706
  if (defaultValue !== undefined) {
657
707
  code += ` else {
658
- ${currentAddComma}
659
- json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
708
+ json += ${JSON.stringify(keyPrefix + JSON.stringify(defaultValue))}
660
709
  }
661
710
  `
662
711
  } else if (isRequired) {
@@ -671,11 +720,16 @@ function buildInnerObject (context, location, objVar) {
671
720
 
672
721
  addComma = 'json += JSON_STR_COMMA'
673
722
  } else {
723
+ // No property is guaranteed to emit: fold `{` into whichever property
724
+ // emits first, and only emit a bare `{` when none does.
674
725
  const needsRuntimeComma = propertiesKeys.length > 1 || schema.patternProperties || (schema.additionalProperties !== undefined && schema.additionalProperties !== false)
675
726
 
676
727
  if (needsRuntimeComma) {
677
728
  code += `let addComma_${localUid} = false\n`
678
- addComma = `!addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)`
729
+ addComma = `json += addComma_${localUid} ? JSON_STR_COMMA : ((addComma_${localUid} = true), JSON_STR_BEGIN_OBJECT)`
730
+ emptyObjectGuard = `if (!addComma_${localUid}) json += JSON_STR_BEGIN_OBJECT\n`
731
+ } else if (propertiesKeys.length === 0) {
732
+ code += 'json += JSON_STR_BEGIN_OBJECT\n'
679
733
  }
680
734
 
681
735
  for (const key of propertiesKeys) {
@@ -688,12 +742,10 @@ function buildInnerObject (context, location, objVar) {
688
742
  const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
689
743
  const defaultValue = propertyLocation.schema.default
690
744
  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 + ':'
745
+ const keyColon = sanitizedKey + ':'
693
746
  const addProperty = needsRuntimeComma
694
- ? `json += addComma_${localUid} ? ${JSON.stringify(',' + propertyPrefix)} : ${JSON.stringify(propertyPrefix)}
695
- addComma_${localUid} = true`
696
- : `json += ${JSON.stringify(propertyPrefix)}`
747
+ ? `json += addComma_${localUid} ? ${JSON.stringify(',' + keyColon)} : ((addComma_${localUid} = true), ${JSON.stringify('{' + keyColon)})`
748
+ : `json += ${JSON.stringify('{' + keyColon)}`
697
749
 
698
750
  code += `
699
751
  const ${value} = ${objVar}[${sanitizedKey}]
@@ -703,17 +755,34 @@ function buildInnerObject (context, location, objVar) {
703
755
  }`
704
756
 
705
757
  if (defaultValue !== undefined) {
706
- code += ` else {
758
+ const defaultValueLiteral = JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))
759
+ if (needsRuntimeComma) {
760
+ code += ` else {
707
761
  ${addComma}
708
- json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
762
+ json += ${defaultValueLiteral}
763
+ }
764
+ `
765
+ } else {
766
+ // Single optional property with default: `{` is folded into the
767
+ // default literal so the absent branch still opens the object.
768
+ code += ` else {
769
+ json += ${JSON.stringify('{' + sanitizedKey + ':' + JSON.stringify(defaultValue))}
709
770
  }
710
771
  `
772
+ }
711
773
  } else if (isRequired) {
712
774
  // Should not happen if requiredProperties.length === 0 but safety
713
775
  code += ` else {
714
776
  throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
715
777
  }
716
778
  `
779
+ } else if (!needsRuntimeComma) {
780
+ // Single optional property: `{` is folded into its key, so emit `{`
781
+ // when the property is absent.
782
+ code += ` else {
783
+ json += JSON_STR_BEGIN_OBJECT
784
+ }
785
+ `
717
786
  } else {
718
787
  code += '\n'
719
788
  }
@@ -724,9 +793,15 @@ function buildInnerObject (context, location, objVar) {
724
793
  code += buildExtraObjectPropertiesSerializer(context, location, addComma, objVar)
725
794
  }
726
795
 
727
- code += `
728
- json += JSON_STR_END_OBJECT
729
- `
796
+ if (emptyObjectGuard !== null) {
797
+ code += emptyObjectGuard
798
+ }
799
+
800
+ // Named object serializers can return `json + '}'` directly, saving one
801
+ // append + intermediate assignment in the hot per-item path.
802
+ code += isReturnForm
803
+ ? '\nreturn json + JSON_STR_END_OBJECT\n'
804
+ : '\njson += JSON_STR_END_OBJECT\n'
730
805
  return code
731
806
  }
732
807
 
@@ -820,8 +895,7 @@ function buildObject (context, location, input) {
820
895
  if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
821
896
  let json = ''
822
897
 
823
- ${buildInnerObject(context, location, 'obj')}
824
- return json
898
+ ${buildInnerObject(context, location, 'obj', true)}
825
899
  }
826
900
  `
827
901
 
@@ -843,6 +917,73 @@ function buildObject (context, location, input) {
843
917
  return code
844
918
  }
845
919
 
920
+ // Per-element condition under which a native JSON.stringify(array) is
921
+ // byte-identical to the generated per-item serializer for primitive item
922
+ // schemas, plus the minimum array length at which copying beats the existing
923
+ // loop. Returns null when the item schema uses any keyword that changes
924
+ // serialization dispatch, or when the kind never wins (boolean: 'true'/'false'
925
+ // concat is already cheap; the copy loses 25-44% at every size on Node 24).
926
+ function getPrimitiveArrayFastPathCondition (itemsSchema) {
927
+ if (
928
+ Array.isArray(itemsSchema) ||
929
+ itemsSchema.$ref !== undefined ||
930
+ typeof itemsSchema.type !== 'string' ||
931
+ itemsSchema.const !== undefined ||
932
+ itemsSchema.nullable === true ||
933
+ itemsSchema.allOf !== undefined ||
934
+ itemsSchema.anyOf !== undefined ||
935
+ itemsSchema.oneOf !== undefined ||
936
+ itemsSchema.if !== undefined
937
+ ) {
938
+ return null
939
+ }
940
+ switch (itemsSchema.type) {
941
+ case 'number':
942
+ // asNumber(): NaN throws, Infinity serializes as null, every other
943
+ // number renders via ToString, exactly like JSON.stringify. The cutoff
944
+ // is in NUMBER_ARRAY_FAST_PATH.
945
+ return NUMBER_ARRAY_FAST_PATH
946
+ case 'integer':
947
+ // asInteger(): only integer numbers skip the (rounding-dependent)
948
+ // parseInteger path; those render via ToString like JSON.stringify.
949
+ // Small-int ToString is cached, so the loop stays cheap for longer;
950
+ // the cutoff is in INTEGER_ARRAY_FAST_PATH.
951
+ return INTEGER_ARRAY_FAST_PATH
952
+ default:
953
+ return null
954
+ }
955
+ }
956
+
957
+ // Item schema { type: 'string' } with no format, nullable, const, or combinator.
958
+ // Other keywords (enum, pattern, minLength) are not applied while serializing.
959
+ function isPlainStringItemsSchema (schema) {
960
+ if (schema.type !== 'string') {
961
+ return false
962
+ }
963
+ if (schema.format !== undefined) {
964
+ return false
965
+ }
966
+ if (schema.nullable === true) {
967
+ return false
968
+ }
969
+ if (schema.const !== undefined) {
970
+ return false
971
+ }
972
+ if (schema.allOf !== undefined) {
973
+ return false
974
+ }
975
+ if (schema.anyOf !== undefined) {
976
+ return false
977
+ }
978
+ if (schema.oneOf !== undefined) {
979
+ return false
980
+ }
981
+ if (schema.if !== undefined) {
982
+ return false
983
+ }
984
+ return true
985
+ }
986
+
846
987
  function buildArray (context, location, input) {
847
988
  const schema = location.schema
848
989
 
@@ -854,6 +995,9 @@ function buildArray (context, location, input) {
854
995
  }
855
996
 
856
997
  const itemsSchema = itemsLocation.schema
998
+ const plainStringFastPath = context.largeArrayMechanism !== 'json-stringify' &&
999
+ !Array.isArray(itemsSchema) &&
1000
+ isPlainStringItemsSchema(itemsSchema)
857
1001
 
858
1002
  if (context.functionsNamesBySchema.has(schema)) {
859
1003
  const funcName = context.functionsNamesBySchema.get(schema)
@@ -896,6 +1040,47 @@ function buildArray (context, location, input) {
896
1040
 
897
1041
  if (context.largeArrayMechanism === 'json-stringify') {
898
1042
  functionCode += `if (arrayLength >= ${context.largeArraySize}) return JSON.stringify(obj)\n`
1043
+ } else if (plainStringFastPath) {
1044
+ // Copy strings once; the helper resumes ordered coercion on the first miss.
1045
+ functionCode += `
1046
+ if (typeof arrayLength === 'number' && arrayLength >= ${context.largeArraySize} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
1047
+ return asPlainStringArray(obj, arrayLength)
1048
+ }
1049
+ `
1050
+ } else {
1051
+ const fastPath = getPrimitiveArrayFastPathCondition(itemsSchema)
1052
+ if (fastPath) {
1053
+ const valueCode = buildValue(context, itemsLocation, 'v')
1054
+ functionCode += `
1055
+ // Copying small arrays costs more than the existing loop.
1056
+ if (typeof arrayLength === 'number' && arrayLength >= ${fastPath.minLength} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
1057
+ const copy = new Array(arrayLength)
1058
+ // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires
1059
+ // the standard Array.prototype; a null prototype halves its throughput.
1060
+ // Only detach the prototype when a toJSON hook would otherwise be called.
1061
+ if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null)
1062
+ for (let i = 0; i < arrayLength; i++) {
1063
+ const v = obj[i]
1064
+ if (!(${fastPath.condition})) {
1065
+ // Serialize the captured prefix, then resume at the first miss.
1066
+ // Never reread getters or read ahead of an item's coercion.
1067
+ copy.length = i
1068
+ json = JSON.stringify(copy).slice(0, -1)
1069
+ if (i > 0) json += JSON_STR_COMMA
1070
+ ${valueCode}
1071
+ for (i++; i < arrayLength; i++) {
1072
+ json += JSON_STR_COMMA
1073
+ const v = obj[i]
1074
+ ${valueCode}
1075
+ }
1076
+ return json + JSON_STR_END_ARRAY
1077
+ }
1078
+ copy[i] = v
1079
+ }
1080
+ return JSON.stringify(copy)
1081
+ }
1082
+ `
1083
+ }
899
1084
  }
900
1085
 
901
1086
  functionCode += `
@@ -981,6 +1166,13 @@ function buildArray (context, location, input) {
981
1166
 
982
1167
  if (context.largeArrayMechanism === 'json-stringify') {
983
1168
  inlinedCode += `if (arrayLength_${objVar} >= ${context.largeArraySize}) json += JSON.stringify(${objVar})\n else {`
1169
+ } else if (plainStringFastPath) {
1170
+ inlinedCode += `
1171
+ let fjsPlain_${objVar}
1172
+ if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${context.largeArraySize} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
1173
+ if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
1174
+ else {
1175
+ `
984
1176
  }
985
1177
 
986
1178
  inlinedCode += `
@@ -1037,7 +1229,7 @@ function buildArray (context, location, input) {
1037
1229
  json += JSON_STR_END_ARRAY
1038
1230
  `
1039
1231
 
1040
- if (context.largeArrayMechanism === 'json-stringify') {
1232
+ if (context.largeArrayMechanism === 'json-stringify' || plainStringFastPath) {
1041
1233
  inlinedCode += '}'
1042
1234
  }
1043
1235
 
@@ -1236,8 +1428,8 @@ function detectRecursiveSchemas (context, location) {
1236
1428
  throw schemaDepthError(context.maxDepth)
1237
1429
  }
1238
1430
 
1239
- const schemaId = location.schemaId || ''
1240
- const jsonPointer = location.jsonPointer || ''
1431
+ const schemaId = location.schemaId
1432
+ const jsonPointer = location.jsonPointer
1241
1433
  const fullPath = `${schemaId}#${jsonPointer}`
1242
1434
 
1243
1435
  if (pathStack.has(fullPath)) {
@@ -1357,6 +1549,9 @@ function buildAllOf (context, location, input) {
1357
1549
  context.mergedSchemasIds.set(schema, mergedSchemaId)
1358
1550
 
1359
1551
  const { allOf, ...schemaWithoutAllOf } = location.schema
1552
+ const dropAdditionalProperties = schemaWithoutAllOf.additionalProperties === false
1553
+ if (dropAdditionalProperties) delete schemaWithoutAllOf.additionalProperties
1554
+
1360
1555
  const locations = [
1361
1556
  new Location(
1362
1557
  schemaWithoutAllOf,
@@ -1371,6 +1566,7 @@ function buildAllOf (context, location, input) {
1371
1566
  }
1372
1567
 
1373
1568
  const mergedLocation = mergeLocations(context, mergedSchemaId, locations)
1569
+ if (dropAdditionalProperties) mergedLocation.schema.additionalProperties = false
1374
1570
  return buildValue(context, mergedLocation, input)
1375
1571
  }
1376
1572
 
package/lib/location.js CHANGED
@@ -9,7 +9,8 @@ class Location {
9
9
 
10
10
  getPropertyLocation (propertyName) {
11
11
  // a JSON pointer segment, so a property name with a / or a ~ still points at the property
12
- const segment = String(propertyName).replace(/~/g, '~0').replace(/\//g, '~1')
12
+ const name = String(propertyName)
13
+ const segment = /[~/]/.test(name) ? name.replace(/~/g, '~0').replace(/\//g, '~1') : name
13
14
  const propertyLocation = new Location(
14
15
  this.schema[propertyName],
15
16
  this.schemaId,
package/lib/serializer.js CHANGED
@@ -135,6 +135,42 @@ module.exports = class Serializer {
135
135
  return '"' + str + '"'
136
136
  }
137
137
 
138
+ // Copy primitive strings once, ignoring array/prototype toJSON hooks. On a
139
+ // miss, serialize the captured prefix and coerce the remaining items in order,
140
+ // matching buildSingleTypeSerializer without rereading getters.
141
+ asPlainStringArray (arr, len = arr.length) {
142
+ const copy = new Array(len)
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)
147
+ for (let i = 0; i < len; i++) {
148
+ const value = arr[i]
149
+ if (typeof value !== 'string') {
150
+ copy.length = i
151
+ let json = JSON.stringify(copy).slice(0, -1)
152
+ for (let j = i; j < len; j++) {
153
+ const item = j === i ? value : arr[j]
154
+ if (j > 0) json += ','
155
+ if (typeof item === 'string') {
156
+ json += asString(item)
157
+ } else if (item === null) {
158
+ json += '""'
159
+ } else if (item instanceof Date) {
160
+ json += '"' + item.toISOString() + '"'
161
+ } else if (item instanceof RegExp) {
162
+ json += asString(item.source)
163
+ } else {
164
+ json += asString(item.toString())
165
+ }
166
+ }
167
+ return json + ']'
168
+ }
169
+ copy[i] = value
170
+ }
171
+ return JSON.stringify(copy)
172
+ }
173
+
138
174
  getState () {
139
175
  return this._options
140
176
  }
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.1",
3
+ "version": "7.1.3",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -749,3 +749,54 @@ test('do not crash with $ref prop', (t) => {
749
749
  })
750
750
  t.assert.equal(value, '{"outside":{"$ref":"true"}}')
751
751
  })
752
+
753
+ test('allOf keeps property constraints when additional properties are disabled', (t) => {
754
+ const schema = {
755
+ additionalProperties: false,
756
+ allOf: [{
757
+ type: 'object',
758
+ properties: {
759
+ entries: {
760
+ type: 'array',
761
+ items: {
762
+ type: 'object',
763
+ properties: { enabled: { type: 'boolean' }, domain: { type: 'string' } },
764
+ required: ['domain']
765
+ }
766
+ }
767
+ }
768
+ }]
769
+ }
770
+ const stringify = build(schema)
771
+ t.assert.deepEqual(JSON.parse(stringify({ entries: [{ enabled: 1, domain: 'example.com' }], extra: true })),
772
+ { entries: [{ enabled: true, domain: 'example.com' }] })
773
+ t.assert.throws(() => stringify({ entries: [{ enabled: 1 }] }), /"domain" is required/)
774
+ })
775
+
776
+ test('allOf keeps root and referenced property constraints with additionalProperties false', (t) => {
777
+ const schema = {
778
+ type: 'object',
779
+ additionalProperties: false,
780
+ properties: { name: { type: 'string' } },
781
+ required: ['name'],
782
+ allOf: [{ $ref: 'entry' }]
783
+ }
784
+ const stringify = build(schema, {
785
+ schema: { entry: { type: 'object', properties: { enabled: { type: 'boolean' } }, required: ['enabled'] } }
786
+ })
787
+ t.assert.deepEqual(JSON.parse(stringify({ name: 'one', enabled: 1, extra: 'drop' })), { name: 'one', enabled: true })
788
+ t.assert.throws(() => stringify({ name: 'one' }), /"enabled" is required/)
789
+ t.assert.throws(() => stringify({ enabled: true }), /"name" is required/)
790
+ t.assert.equal(schema.additionalProperties, false)
791
+ })
792
+
793
+ test('allOf preserves explicit additional property schemas', (t) => {
794
+ for (const additionalProperties of [true, { type: 'boolean' }]) {
795
+ const stringify = build({
796
+ type: 'object',
797
+ additionalProperties,
798
+ allOf: [{ type: 'object', properties: { enabled: { type: 'boolean' } } }]
799
+ })
800
+ t.assert.deepEqual(JSON.parse(stringify({ enabled: 1, extra: true })), { enabled: true, extra: true })
801
+ }
802
+ })
@@ -711,3 +711,125 @@ buildTest({
711
711
  largeArraySize: '10000',
712
712
  largeArrayMechanism: 'default'
713
713
  })
714
+
715
+ buildTest({
716
+ title: 'string array whose items declare const',
717
+ type: 'array',
718
+ items: { type: 'string', const: 'a' }
719
+ }, ['a', 'a'])
720
+
721
+ buildTest({
722
+ title: 'string array whose items declare allOf',
723
+ type: 'array',
724
+ items: { type: 'string', allOf: [{ type: 'string' }] }
725
+ }, ['a', 'b'])
726
+
727
+ buildTest({
728
+ title: 'string array whose items declare anyOf',
729
+ type: 'array',
730
+ items: { type: 'string', anyOf: [{ type: 'string' }] }
731
+ }, ['a', 'b'])
732
+
733
+ buildTest({
734
+ title: 'string array whose items declare oneOf',
735
+ type: 'array',
736
+ items: { type: 'string', oneOf: [{ type: 'string' }] }
737
+ }, ['a', 'b'])
738
+
739
+ buildTest({
740
+ title: 'string array whose items declare if',
741
+ type: 'array',
742
+ items: { type: 'string', if: { type: 'string' }, then: { type: 'string' } }
743
+ }, ['a', 'b'])
744
+
745
+ test('string array coerces a non-string item', (t) => {
746
+ const stringify = build({
747
+ type: 'array',
748
+ items: { type: 'string' }
749
+ }, { largeArraySize: 2 })
750
+ const date = new Date('2020-01-02T03:04:05.006Z')
751
+
752
+ t.assert.equal(
753
+ stringify([1, 'a', null, date, /re/]),
754
+ '["1","a","","2020-01-02T03:04:05.006Z","re"]'
755
+ )
756
+ })
757
+
758
+ // Large number/integer arrays are copied and handed to JSON.stringify. The
759
+ // first element that is not already the right type switches to the per-item
760
+ // loop from that index on. 3e5 elements is the largest fast-path cutoff on
761
+ // any supported Node release (see PRIMITIVE_FAST_PATH_MIN_LENGTH in index.js),
762
+ // so these always exercise the resume path.
763
+ const FAST_PATH_LENGTH = 3e5
764
+
765
+ for (const [type, bad, expected] of [
766
+ ['integer', 1.9, '1'],
767
+ ['integer', '7', '7'],
768
+ ['integer', null, '0'],
769
+ ['number', '7.5', '7.5'],
770
+ ['number', null, '0'],
771
+ ['number', true, '1']
772
+ ]) {
773
+ for (const [position, index] of [
774
+ ['start', 0],
775
+ ['middle', FAST_PATH_LENGTH >> 1],
776
+ ['end', FAST_PATH_LENGTH - 1]
777
+ ]) {
778
+ test(`large ${type} array falls back to the loop on ${JSON.stringify(bad)} at the ${position}`, (t) => {
779
+ const stringify = build({ type: 'array', items: { type } })
780
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i - 1000)
781
+ input[index] = bad
782
+
783
+ const reference = Array.from(input, (v, i) => i === index ? expected : String(v))
784
+ t.assert.equal(stringify(input), '[' + reference.join(',') + ']')
785
+ })
786
+ }
787
+ }
788
+
789
+ test('large integer array applies the rounding option after the fallback', (t) => {
790
+ const stringify = build({ type: 'array', items: { type: 'integer' } }, { rounding: 'ceil' })
791
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)
792
+ input[10] = 1.1
793
+ input[FAST_PATH_LENGTH - 1] = 2.1
794
+
795
+ const output = stringify(input)
796
+ t.assert.equal(output.slice(0, 23), '[0,1,2,3,4,5,6,7,8,9,2,')
797
+ t.assert.equal(output.slice(-3), ',3]')
798
+ })
799
+
800
+ test('large number array still throws on a non-numeric string after the fallback', (t) => {
801
+ const stringify = build({ type: 'array', items: { type: 'number' } })
802
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i / 10)
803
+ input[FAST_PATH_LENGTH >> 1] = 'not a number'
804
+
805
+ t.assert.throws(() => stringify(input), new Error('The value "not a number" cannot be converted to a number.'))
806
+ })
807
+
808
+ // A real NaN is typeof 'number', so only the `v === v` half of the fast-path
809
+ // condition rejects it. Check it at every position the copy loop can stop at.
810
+ for (const [position, index] of [['start', 0], ['middle', FAST_PATH_LENGTH >> 1], ['end', FAST_PATH_LENGTH - 1]]) {
811
+ test(`large number array still throws on NaN at the ${position} after the fallback`, (t) => {
812
+ const stringify = build({ type: 'array', items: { type: 'number' } })
813
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i / 10)
814
+ input[index] = NaN
815
+
816
+ t.assert.throws(() => stringify(input), new Error('The value "NaN" cannot be converted to a number.'))
817
+ })
818
+ }
819
+
820
+ test('large number array reads each element once across the fallback', (t) => {
821
+ const stringify = build({ type: 'array', items: { type: 'number' } })
822
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)
823
+ const reads = []
824
+ for (const index of [0, 1, FAST_PATH_LENGTH >> 1, FAST_PATH_LENGTH - 1]) {
825
+ Object.defineProperty(input, index, {
826
+ get () {
827
+ reads.push(index)
828
+ return index === 1 ? '1' : index
829
+ }
830
+ })
831
+ }
832
+
833
+ t.assert.equal(stringify(input), JSON.stringify(Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)))
834
+ t.assert.deepEqual(reads, [0, 1, FAST_PATH_LENGTH >> 1, FAST_PATH_LENGTH - 1])
835
+ })
@@ -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
+ }
@@ -110,11 +110,23 @@ test('rejects invalid maximum schema depths', (t) => {
110
110
  test('checks schema dependencies without treating property lists as schemas', (t) => {
111
111
  t.assert.doesNotThrow(() => build({
112
112
  type: 'object',
113
+ definitions: {
114
+ valueProp: { type: 'string' },
115
+ any: true
116
+ },
117
+ // Include a $ref so recursive-schema detection runs and walks dependency
118
+ // schema forms (property-list dependencies must still be ignored). Boolean
119
+ // schemas must also be visited so traverse's non-object early-return stays covered.
120
+ properties: {
121
+ link: { $ref: '#/definitions/valueProp' },
122
+ flag: { $ref: '#/definitions/any' },
123
+ open: true
124
+ },
113
125
  dependencies: {
114
- enabled: { properties: { value: { type: 'string' } } },
126
+ enabled: { properties: { value: { $ref: '#/definitions/valueProp' } } },
115
127
  value: ['enabled']
116
128
  }
117
- }, { maxDepth: 2 }))
129
+ }, { maxDepth: 3 }))
118
130
  })
119
131
 
120
132
  test('checks external schemas before validating them recursively', (t) => {
@@ -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
+ }