fast-json-stringify 7.1.1 → 7.1.2

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
@@ -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 null-prototype 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,44 @@ 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
+ Object.setPrototypeOf(copy, null)
1059
+ for (let i = 0; i < arrayLength; i++) {
1060
+ const v = obj[i]
1061
+ if (!(${fastPath.condition})) {
1062
+ // Serialize the captured prefix, then resume at the first miss.
1063
+ // Never reread getters or read ahead of an item's coercion.
1064
+ copy.length = i
1065
+ json = JSON.stringify(copy).slice(0, -1)
1066
+ if (i > 0) json += JSON_STR_COMMA
1067
+ ${valueCode}
1068
+ for (i++; i < arrayLength; i++) {
1069
+ json += JSON_STR_COMMA
1070
+ const v = obj[i]
1071
+ ${valueCode}
1072
+ }
1073
+ return json + JSON_STR_END_ARRAY
1074
+ }
1075
+ copy[i] = v
1076
+ }
1077
+ return JSON.stringify(copy)
1078
+ }
1079
+ `
1080
+ }
899
1081
  }
900
1082
 
901
1083
  functionCode += `
@@ -981,6 +1163,13 @@ function buildArray (context, location, input) {
981
1163
 
982
1164
  if (context.largeArrayMechanism === 'json-stringify') {
983
1165
  inlinedCode += `if (arrayLength_${objVar} >= ${context.largeArraySize}) json += JSON.stringify(${objVar})\n else {`
1166
+ } else if (plainStringFastPath) {
1167
+ inlinedCode += `
1168
+ let fjsPlain_${objVar}
1169
+ if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${context.largeArraySize} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
1170
+ if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
1171
+ else {
1172
+ `
984
1173
  }
985
1174
 
986
1175
  inlinedCode += `
@@ -1037,7 +1226,7 @@ function buildArray (context, location, input) {
1037
1226
  json += JSON_STR_END_ARRAY
1038
1227
  `
1039
1228
 
1040
- if (context.largeArrayMechanism === 'json-stringify') {
1229
+ if (context.largeArrayMechanism === 'json-stringify' || plainStringFastPath) {
1041
1230
  inlinedCode += '}'
1042
1231
  }
1043
1232
 
@@ -1236,8 +1425,8 @@ function detectRecursiveSchemas (context, location) {
1236
1425
  throw schemaDepthError(context.maxDepth)
1237
1426
  }
1238
1427
 
1239
- const schemaId = location.schemaId || ''
1240
- const jsonPointer = location.jsonPointer || ''
1428
+ const schemaId = location.schemaId
1429
+ const jsonPointer = location.jsonPointer
1241
1430
  const fullPath = `${schemaId}#${jsonPointer}`
1242
1431
 
1243
1432
  if (pathStack.has(fullPath)) {
@@ -1357,6 +1546,9 @@ function buildAllOf (context, location, input) {
1357
1546
  context.mergedSchemasIds.set(schema, mergedSchemaId)
1358
1547
 
1359
1548
  const { allOf, ...schemaWithoutAllOf } = location.schema
1549
+ const dropAdditionalProperties = schemaWithoutAllOf.additionalProperties === false
1550
+ if (dropAdditionalProperties) delete schemaWithoutAllOf.additionalProperties
1551
+
1360
1552
  const locations = [
1361
1553
  new Location(
1362
1554
  schemaWithoutAllOf,
@@ -1371,6 +1563,7 @@ function buildAllOf (context, location, input) {
1371
1563
  }
1372
1564
 
1373
1565
  const mergedLocation = mergeLocations(context, mergedSchemaId, locations)
1566
+ if (dropAdditionalProperties) mergedLocation.schema.additionalProperties = false
1374
1567
  return buildValue(context, mergedLocation, input)
1375
1568
  }
1376
1569
 
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,39 @@ 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
+ Object.setPrototypeOf(copy, null)
144
+ for (let i = 0; i < len; i++) {
145
+ const value = arr[i]
146
+ if (typeof value !== 'string') {
147
+ copy.length = i
148
+ let json = JSON.stringify(copy).slice(0, -1)
149
+ for (let j = i; j < len; j++) {
150
+ const item = j === i ? value : arr[j]
151
+ if (j > 0) json += ','
152
+ if (typeof item === 'string') {
153
+ json += asString(item)
154
+ } else if (item === null) {
155
+ json += '""'
156
+ } else if (item instanceof Date) {
157
+ json += '"' + item.toISOString() + '"'
158
+ } else if (item instanceof RegExp) {
159
+ json += asString(item.source)
160
+ } else {
161
+ json += asString(item.toString())
162
+ }
163
+ }
164
+ return json + ']'
165
+ }
166
+ copy[i] = value
167
+ }
168
+ return JSON.stringify(copy)
169
+ }
170
+
138
171
  getState () {
139
172
  return this._options
140
173
  }
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.2",
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
+ })
@@ -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) => {