fast-json-stringify 6.1.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,11 @@ on:
14
14
  - 'docs/**'
15
15
  - '*.md'
16
16
 
17
+ # This allows a subsequently queued workflow run to interrupt previous runs
18
+ concurrency:
19
+ group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
20
+ cancel-in-progress: true
21
+
17
22
  permissions:
18
23
  contents: read
19
24
 
package/README.md CHANGED
@@ -7,7 +7,6 @@
7
7
 
8
8
  __fast-json-stringify__ is significantly faster than `JSON.stringify()` for small payloads.
9
9
  Its performance advantage shrinks as your payload grows.
10
- It pairs well with [__flatstr__](https://www.npmjs.com/package/flatstr), which triggers a V8 optimization that improves performance when eventually converting the string to a `Buffer`.
11
10
 
12
11
 
13
12
  ### How it works
@@ -80,7 +79,6 @@ compile-json-stringify date format x 803,214 ops/sec ±0.23% (97 runs sampled)
80
79
 
81
80
 
82
81
  <a name="example"></a>
83
- Try it out on RunKit: <a href="https://runkit.com/npm/fast-json-stringify">https://runkit.com/npm/fast-json-stringify</a>
84
82
  ## Example
85
83
 
86
84
  ```js
@@ -348,9 +346,9 @@ const stringify = fastJson({
348
346
  properties: {
349
347
  'undecidedType': {
350
348
  'anyOf': [{
351
- type: 'string'
349
+ type: 'string'
352
350
  }, {
353
- type: 'boolean'
351
+ type: 'boolean'
354
352
  }]
355
353
  }
356
354
  }
@@ -646,8 +644,8 @@ const stringify = fastJson({
646
644
  type: 'object',
647
645
  properties: {
648
646
  'code': {
649
- type: 'string',
650
- format 'unsafe'
647
+ type: 'string',
648
+ format: 'unsafe'
651
649
  }
652
650
  }
653
651
  })
@@ -1,7 +1,15 @@
1
1
  'use strict'
2
2
 
3
- const benchmark = require('benchmark')
4
- const suite = new benchmark.Suite()
3
+ const { Bench } = require('tinybench')
4
+ const suite = new Bench({
5
+ name: 'Library Comparison Benchmarks',
6
+ setup: (_task, mode) => {
7
+ // Run the garbage collector before warmup at each cycle
8
+ if (mode === 'warmup' && typeof globalThis.gc === 'function') {
9
+ globalThis.gc()
10
+ }
11
+ }
12
+ })
5
13
 
6
14
  const STR_LEN = 1e4
7
15
  const LARGE_ARRAY_SIZE = 2e4
@@ -76,13 +84,13 @@ const arraySchemaAJVJTD = {
76
84
  const dateFormatSchema = {
77
85
  description: 'Date of birth',
78
86
  type: 'string',
79
- format: 'date'
87
+ format: 'datetime'
80
88
  }
81
89
 
82
90
  const dateFormatSchemaCJS = {
83
91
  description: 'Date of birth',
84
92
  type: 'string',
85
- format: 'date'
93
+ format: 'datetime'
86
94
  }
87
95
 
88
96
  const obj = {
@@ -300,10 +308,16 @@ suite.add('compile-json-stringify date format', function () {
300
308
  CJSStringifyDate(date)
301
309
  })
302
310
 
303
- suite.on('cycle', cycle)
311
+ suite.run().then(() => {
312
+ for (const task of suite.tasks) {
313
+ const hz = task.result.throughput.mean // ops/sec
314
+ const rme = task.result.latency.rme // relative margin of error (%)
315
+ const samples = task.result.latency.df + 1 // degrees of freedom + 1 = sample count
304
316
 
305
- suite.run()
317
+ const formattedHz = hz.toLocaleString('en-US', { maximumFractionDigits: 0 })
318
+ const formattedRme = rme.toFixed(2)
306
319
 
307
- function cycle (e) {
308
- console.log(e.target.toString())
309
- }
320
+ const output = `${task.name} x ${formattedHz} ops/sec ±${formattedRme}% (${samples} runs sampled)`
321
+ console.log(output)
322
+ }
323
+ }).catch(err => console.error(`Error: ${err.message}`))
@@ -2,20 +2,32 @@
2
2
 
3
3
  const { workerData: benchmark, parentPort } = require('worker_threads')
4
4
 
5
- const Benchmark = require('benchmark')
6
- Benchmark.options.minSamples = 100
5
+ const { Bench } = require('tinybench')
7
6
 
8
- const suite = Benchmark.Suite()
7
+ const bench = new Bench({
8
+ name: benchmark.name,
9
+ setup: (_task, mode) => {
10
+ // Run the garbage collector before warmup at each cycle
11
+ if (mode === 'warmup' && typeof globalThis.gc === 'function') {
12
+ globalThis.gc()
13
+ }
14
+ }
15
+ })
9
16
 
10
17
  const FJS = require('..')
11
18
  const stringify = FJS(benchmark.schema)
12
19
 
13
- suite
14
- .add(benchmark.name, () => {
15
- stringify(benchmark.input)
16
- })
17
- .on('cycle', (event) => {
18
- parentPort.postMessage(String(event.target))
19
- })
20
- .on('complete', () => {})
21
- .run()
20
+ bench.add(benchmark.name, () => {
21
+ stringify(benchmark.input)
22
+ }).run().then(() => {
23
+ const task = bench.tasks[0]
24
+ const hz = task.result.throughput.mean // ops/sec
25
+ const rme = task.result.latency.rme // relative margin of error (%)
26
+ const samples = task.result.latency.df + 1 // degrees of freedom + 1 = sample count
27
+
28
+ const formattedHz = hz.toLocaleString('en-US', { maximumFractionDigits: 0 })
29
+ const formattedRme = rme.toFixed(2)
30
+
31
+ const output = `${task.name} x ${formattedHz} ops/sec ±${formattedRme}% (${samples} runs sampled)`
32
+ parentPort.postMessage(output)
33
+ }).catch(err => parentPort.postMessage(`Error: ${err.message}`))
package/index.js CHANGED
@@ -30,17 +30,17 @@ const asInteger = serializer.asInteger.bind(serializer)
30
30
 
31
31
  `
32
32
 
33
- const validRoundingMethods = [
33
+ const validRoundingMethods = new Set([
34
34
  'floor',
35
35
  'ceil',
36
36
  'round',
37
37
  'trunc'
38
- ]
38
+ ])
39
39
 
40
- const validLargeArrayMechanisms = [
40
+ const validLargeArrayMechanisms = new Set([
41
41
  'default',
42
42
  'json-stringify'
43
- ]
43
+ ])
44
44
 
45
45
  let schemaIdCounter = 0
46
46
 
@@ -94,6 +94,14 @@ function getSchemaId (schema, rootSchemaId) {
94
94
  return rootSchemaId
95
95
  }
96
96
 
97
+ function getSafeSchemaRef (context, location) {
98
+ let schemaRef = location.getSchemaRef() || ''
99
+ if (schemaRef.startsWith(context.rootSchemaId)) {
100
+ schemaRef = schemaRef.replace(context.rootSchemaId, '') || '#'
101
+ }
102
+ return schemaRef
103
+ }
104
+
97
105
  function build (schema, options) {
98
106
  isValidSchema(schema)
99
107
 
@@ -107,7 +115,11 @@ function build (schema, options) {
107
115
  refResolver: new RefResolver(),
108
116
  rootSchemaId: schema.$id || `__fjs_root_${schemaIdCounter++}`,
109
117
  validatorSchemasIds: new Set(),
110
- mergedSchemasIds: new Map()
118
+ mergedSchemasIds: new Map(),
119
+ recursiveSchemas: new Set(),
120
+ recursivePaths: new Set(),
121
+ buildingSet: new Set(),
122
+ uid: 0
111
123
  }
112
124
 
113
125
  const schemaId = getSchemaId(schema, context.rootSchemaId)
@@ -127,13 +139,13 @@ function build (schema, options) {
127
139
  }
128
140
 
129
141
  if (options.rounding) {
130
- if (!validRoundingMethods.includes(options.rounding)) {
142
+ if (!validRoundingMethods.has(options.rounding)) {
131
143
  throw new Error(`Unsupported integer rounding method ${options.rounding}`)
132
144
  }
133
145
  }
134
146
 
135
147
  if (options.largeArrayMechanism) {
136
- if (validLargeArrayMechanisms.includes(options.largeArrayMechanism)) {
148
+ if (validLargeArrayMechanisms.has(options.largeArrayMechanism)) {
137
149
  largeArrayMechanism = options.largeArrayMechanism
138
150
  } else {
139
151
  throw new Error(`Unsupported large array mechanism ${options.largeArrayMechanism}`)
@@ -141,11 +153,14 @@ function build (schema, options) {
141
153
  }
142
154
 
143
155
  if (options.largeArraySize) {
144
- if (typeof options.largeArraySize === 'string' && Number.isFinite(Number.parseInt(options.largeArraySize, 10))) {
145
- largeArraySize = Number.parseInt(options.largeArraySize, 10)
146
- } else if (typeof options.largeArraySize === 'number' && Number.isInteger(options.largeArraySize)) {
156
+ const largeArraySizeType = typeof options.largeArraySize
157
+ let parsedNumber
158
+
159
+ if (largeArraySizeType === 'string' && Number.isFinite((parsedNumber = Number.parseInt(options.largeArraySize, 10)))) {
160
+ largeArraySize = parsedNumber
161
+ } else if (largeArraySizeType === 'number' && Number.isInteger(options.largeArraySize)) {
147
162
  largeArraySize = options.largeArraySize
148
- } else if (typeof options.largeArraySize === 'bigint') {
163
+ } else if (largeArraySizeType === 'bigint') {
149
164
  largeArraySize = Number(options.largeArraySize)
150
165
  } else {
151
166
  throw new Error(`Unsupported large array size. Expected integer-like, got ${typeof options.largeArraySize} with value ${options.largeArraySize}`)
@@ -153,6 +168,7 @@ function build (schema, options) {
153
168
  }
154
169
 
155
170
  const location = new Location(schema, context.rootSchemaId)
171
+ detectRecursiveSchemas(context, location)
156
172
  const code = buildValue(context, location, 'input')
157
173
 
158
174
  let contextFunctionCode = `
@@ -283,19 +299,19 @@ function inferTypeByKeyword (schema) {
283
299
  return schema.type
284
300
  }
285
301
 
286
- function buildExtraObjectPropertiesSerializer (context, location, addComma) {
302
+ function buildExtraObjectPropertiesSerializer (context, location, addComma, objVar) {
287
303
  const schema = location.schema
288
304
  const propertiesKeys = Object.keys(schema.properties || {})
289
305
 
290
306
  let code = `
291
- const propertiesKeys = ${JSON.stringify(propertiesKeys)}
292
- for (const [key, value] of Object.entries(obj)) {
307
+ for (const key of Object.keys(${objVar})) {
293
308
  if (
294
- propertiesKeys.includes(key) ||
295
- value === undefined ||
296
- typeof value === 'function' ||
297
- typeof value === 'symbol'
309
+ ${propertiesKeys.length > 0 ? propertiesKeys.map(k => `key === ${JSON.stringify(k)}`).join(' || ') + ' ||' : ''}
310
+ ${objVar}[key] === undefined ||
311
+ typeof ${objVar}[key] === 'function' ||
312
+ typeof ${objVar}[key] === 'symbol'
298
313
  ) continue
314
+ const value = ${objVar}[key]
299
315
  `
300
316
 
301
317
  const patternPropertiesLocation = location.getPropertyLocation('patternProperties')
@@ -341,7 +357,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma) {
341
357
  return code
342
358
  }
343
359
 
344
- function buildInnerObject (context, location) {
360
+ function buildInnerObject (context, location, objVar) {
345
361
  const schema = location.schema
346
362
 
347
363
  const propertiesLocation = location.getPropertyLocation('properties')
@@ -355,75 +371,128 @@ function buildInnerObject (context, location) {
355
371
  return required1 === required2 ? 0 : required1 ? -1 : 1
356
372
  }
357
373
  )
358
- const hasRequiredProperties = requiredProperties.includes(propertiesKeys[0])
359
374
 
360
- let code = 'let value\n'
375
+ let code = ''
361
376
 
362
377
  for (const key of requiredProperties) {
363
378
  if (!propertiesKeys.includes(key)) {
364
379
  const sanitizedKey = JSON.stringify(key)
365
- code += `if (obj[${sanitizedKey}] === undefined) throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')\n`
380
+ code += `if (${objVar}[${sanitizedKey}] === undefined) throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')\n`
366
381
  }
367
382
  }
368
383
 
369
- code += 'let json = JSON_STR_BEGIN_OBJECT\n'
384
+ code += 'json += JSON_STR_BEGIN_OBJECT\n'
370
385
 
386
+ const localUid = context.uid++
371
387
  let addComma = ''
372
- if (!hasRequiredProperties) {
373
- code += 'let addComma = false\n'
374
- addComma = '!addComma && (addComma = true) || (json += JSON_STR_COMMA)'
375
- }
376
388
 
377
- for (const key of propertiesKeys) {
378
- let propertyLocation = propertiesLocation.getPropertyLocation(key)
379
- if (propertyLocation.schema.$ref) {
380
- propertyLocation = resolveRef(context, propertyLocation)
381
- }
389
+ if (requiredProperties.length > 0) {
390
+ // If we have required properties, we know that at least one property will be serialized.
391
+ // We can avoid the runtime check for the comma.
382
392
 
383
- const sanitizedKey = JSON.stringify(key)
384
- const defaultValue = propertyLocation.schema.default
385
- const isRequired = requiredProperties.includes(key)
393
+ // The first property is required, so we don't need a comma.
394
+ // For the subsequent properties, we can blindly add a comma.
386
395
 
387
- code += `
388
- value = obj[${sanitizedKey}]
389
- if (value !== undefined) {
390
- ${addComma}
396
+ for (let i = 0; i < propertiesKeys.length; i++) {
397
+ const key = propertiesKeys[i]
398
+ const propertyLocation = propertiesLocation.getPropertyLocation(key)
399
+
400
+ let resolvedLocation = propertyLocation
401
+ if (propertyLocation.schema.$ref) {
402
+ resolvedLocation = resolveRef(context, propertyLocation)
403
+ }
404
+
405
+ const sanitizedKey = JSON.stringify(key)
406
+ const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
407
+ const defaultValue = resolvedLocation.schema.default
408
+ const isRequired = requiredProperties.includes(key)
409
+
410
+ // i === 0 means it's the first property, and it IS required (due to sort). No comma.
411
+ // i > 0 means it follows a required property (or a sequence starting with one). Unconditional comma.
412
+ const currentAddComma = i === 0 ? '' : 'json += JSON_STR_COMMA'
413
+
414
+ code += `
415
+ const ${value} = ${objVar}[${sanitizedKey}]
416
+ if (${value} !== undefined) {
417
+ ${currentAddComma}
391
418
  json += ${JSON.stringify(sanitizedKey + ':')}
392
- ${buildValue(context, propertyLocation, 'value')}
419
+ ${buildValue(context, resolvedLocation, `${value}`)}
393
420
  }`
394
421
 
395
- if (defaultValue !== undefined) {
396
- code += ` else {
397
- ${addComma}
422
+ if (defaultValue !== undefined) {
423
+ code += ` else {
424
+ ${currentAddComma}
398
425
  json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
399
426
  }
400
427
  `
401
- } else if (isRequired) {
402
- code += ` else {
428
+ } else if (isRequired) {
429
+ code += ` else {
403
430
  throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
404
431
  }
405
432
  `
406
- } else {
407
- code += '\n'
433
+ } else {
434
+ code += '\n'
435
+ }
436
+ }
437
+
438
+ addComma = 'json += JSON_STR_COMMA'
439
+ } else {
440
+ const needsRuntimeComma = propertiesKeys.length > 1 || schema.patternProperties || (schema.additionalProperties !== undefined && schema.additionalProperties !== false)
441
+
442
+ if (needsRuntimeComma) {
443
+ code += `let addComma_${localUid} = false\n`
444
+ addComma = `!addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)`
408
445
  }
409
446
 
410
- if (hasRequiredProperties) {
411
- addComma = 'json += \',\''
447
+ for (const key of propertiesKeys) {
448
+ let propertyLocation = propertiesLocation.getPropertyLocation(key)
449
+ if (propertyLocation.schema.$ref) {
450
+ propertyLocation = resolveRef(context, propertyLocation)
451
+ }
452
+
453
+ const sanitizedKey = JSON.stringify(key)
454
+ const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
455
+ const defaultValue = propertyLocation.schema.default
456
+ const isRequired = requiredProperties.includes(key) // Should be false here but good to keep
457
+
458
+ code += `
459
+ const ${value} = ${objVar}[${sanitizedKey}]
460
+ if (${value} !== undefined) {
461
+ ${addComma}
462
+ json += ${JSON.stringify(sanitizedKey + ':')}
463
+ ${buildValue(context, propertyLocation, `${value}`)}
464
+ }`
465
+
466
+ if (defaultValue !== undefined) {
467
+ code += ` else {
468
+ ${addComma}
469
+ json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
470
+ }
471
+ `
472
+ } else if (isRequired) {
473
+ // Should not happen if requiredProperties.length === 0 but safety
474
+ code += ` else {
475
+ throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
476
+ }
477
+ `
478
+ } else {
479
+ code += '\n'
480
+ }
412
481
  }
413
482
  }
414
483
 
415
484
  if (schema.patternProperties || schema.additionalProperties) {
416
- code += buildExtraObjectPropertiesSerializer(context, location, addComma)
485
+ code += buildExtraObjectPropertiesSerializer(context, location, addComma, objVar)
417
486
  }
418
487
 
419
488
  code += `
420
- return json + JSON_STR_END_OBJECT
489
+ json += JSON_STR_END_OBJECT
421
490
  `
422
491
  return code
423
492
  }
424
493
 
425
494
  function mergeLocations (context, mergedSchemaId, mergedLocations) {
426
- for (let i = 0; i < mergedLocations.length; i++) {
495
+ for (let i = 0, mergedLocationsLength = mergedLocations.length; i < mergedLocationsLength; i++) {
427
496
  const location = mergedLocations[i]
428
497
  const schema = location.schema
429
498
  if (schema.$ref) {
@@ -485,40 +554,57 @@ function toJSON (variableName) {
485
554
  `
486
555
  }
487
556
 
488
- function buildObject (context, location) {
557
+ function buildObject (context, location, input) {
489
558
  const schema = location.schema
490
559
 
491
560
  if (context.functionsNamesBySchema.has(schema)) {
492
- return context.functionsNamesBySchema.get(schema)
561
+ const funcName = context.functionsNamesBySchema.get(schema)
562
+ return `json += ${funcName}(${input})`
493
563
  }
494
564
 
495
- const functionName = generateFuncName(context)
496
- context.functionsNamesBySchema.set(schema, functionName)
565
+ const nullable = schema.nullable === true
497
566
 
498
- let schemaRef = location.getSchemaRef()
499
- if (schemaRef.startsWith(context.rootSchemaId)) {
500
- schemaRef = schemaRef.replace(context.rootSchemaId, '')
501
- }
567
+ const schemaId = location.schemaId || ''
568
+ const jsonPointer = location.jsonPointer || ''
569
+ const fullPath = `${schemaId}#${jsonPointer}`
502
570
 
503
- let functionCode = `
504
- `
571
+ if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema)) {
572
+ const functionName = generateFuncName(context)
573
+ context.functionsNamesBySchema.set(schema, functionName)
505
574
 
506
- const nullable = schema.nullable === true
507
- functionCode += `
508
- // ${schemaRef}
509
- function ${functionName} (input) {
510
- const obj = ${toJSON('input')}
511
- ${!nullable ? 'if (obj === null) return JSON_STR_EMPTY_OBJECT' : ''}
575
+ const schemaRef = getSafeSchemaRef(context, location)
512
576
 
513
- ${buildInnerObject(context, location)}
577
+ const functionCode = `
578
+ // ${schemaRef}
579
+ function ${functionName} (input) {
580
+ const obj = ${toJSON('input')}
581
+ if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
582
+ let json = ''
583
+
584
+ ${buildInnerObject(context, location, 'obj')}
585
+ return json
586
+ }
587
+ `
588
+
589
+ context.functions.push(functionCode)
590
+ return `json += ${functionName}(${input})`
591
+ }
592
+
593
+ context.buildingSet.add(schema)
594
+ const objVar = `obj_${context.uid++}`
595
+ const code = `
596
+ const ${objVar} = ${toJSON(input)}
597
+ if (${objVar} === null) {
598
+ json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
599
+ } else {
600
+ ${buildInnerObject(context, location, objVar)}
514
601
  }
515
602
  `
516
-
517
- context.functions.push(functionCode)
518
- return functionName
603
+ context.buildingSet.delete(schema)
604
+ return code
519
605
  }
520
606
 
521
- function buildArray (context, location) {
607
+ function buildArray (context, location, input) {
522
608
  const schema = location.schema
523
609
 
524
610
  let itemsLocation = location.getPropertyLocation('items')
@@ -531,61 +617,155 @@ function buildArray (context, location) {
531
617
  const itemsSchema = itemsLocation.schema
532
618
 
533
619
  if (context.functionsNamesBySchema.has(schema)) {
534
- return context.functionsNamesBySchema.get(schema)
620
+ const funcName = context.functionsNamesBySchema.get(schema)
621
+ return `json += ${funcName}(${input})`
535
622
  }
536
623
 
537
- const functionName = generateFuncName(context)
538
- context.functionsNamesBySchema.set(schema, functionName)
624
+ const nullable = schema.nullable === true
539
625
 
540
- let schemaRef = location.getSchemaRef()
541
- if (schemaRef.startsWith(context.rootSchemaId)) {
542
- schemaRef = schemaRef.replace(context.rootSchemaId, '')
543
- }
626
+ const schemaId = location.schemaId || ''
627
+ const jsonPointer = location.jsonPointer || ''
628
+ const fullPath = `${schemaId}#${jsonPointer}`
544
629
 
545
- let functionCode = `
630
+ if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema)) {
631
+ const functionName = generateFuncName(context)
632
+ context.functionsNamesBySchema.set(schema, functionName)
633
+
634
+ const schemaRef = getSafeSchemaRef(context, location)
635
+
636
+ let functionCode = `
546
637
  function ${functionName} (obj) {
547
638
  // ${schemaRef}
639
+ let json = ''
548
640
  `
549
641
 
550
- const nullable = schema.nullable === true
551
- functionCode += `
552
- ${!nullable ? 'if (obj === null) return JSON_STR_EMPTY_ARRAY' : ''}
642
+ functionCode += `
643
+ if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'}
553
644
  if (!Array.isArray(obj)) {
554
645
  throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`)
555
646
  }
556
647
  const arrayLength = obj.length
557
648
  `
558
649
 
559
- if (!schema.additionalItems && Array.isArray(itemsSchema)) {
560
- functionCode += `
650
+ if (!schema.additionalItems && Array.isArray(itemsSchema)) {
651
+ functionCode += `
561
652
  if (arrayLength > ${itemsSchema.length}) {
562
653
  throw new Error(\`Item at ${itemsSchema.length} does not match schema definition.\`)
563
654
  }
564
655
  `
656
+ }
657
+
658
+ if (largeArrayMechanism === 'json-stringify') {
659
+ functionCode += `if (arrayLength >= ${largeArraySize}) return JSON.stringify(obj)\n`
660
+ }
661
+
662
+ functionCode += `
663
+ json += JSON_STR_BEGIN_ARRAY
664
+ `
665
+
666
+ if (Array.isArray(itemsSchema)) {
667
+ for (let i = 0, itemsSchemaLength = itemsSchema.length; i < itemsSchemaLength; i++) {
668
+ let item = itemsSchema[i]
669
+ let itemLocation = itemsLocation.getPropertyLocation(i)
670
+ if (itemLocation.schema.$ref) {
671
+ itemLocation = resolveRef(context, itemLocation)
672
+ item = itemLocation.schema
673
+ }
674
+ const value = `value_${i}`
675
+ functionCode += `const ${value} = obj[${i}]`
676
+ const tmpRes = buildValue(context, itemLocation, value)
677
+ functionCode += `
678
+ if (${i} < arrayLength) {
679
+ if (${buildArrayTypeCondition(item.type, value)}) {
680
+ if (${i}) {
681
+ json += JSON_STR_COMMA
682
+ }
683
+ ${tmpRes}
684
+ } else {
685
+ throw new Error(\`Item at ${i} does not match schema definition.\`)
686
+ }
687
+ }
688
+ `
689
+ }
690
+
691
+ if (schema.additionalItems) {
692
+ functionCode += `
693
+ for (let i = ${itemsSchema.length}; i < arrayLength; i++) {
694
+ if (i) {
695
+ json += JSON_STR_COMMA
696
+ }
697
+ json += JSON.stringify(obj[i])
698
+ }`
699
+ }
700
+ } else {
701
+ const code = buildValue(context, itemsLocation, 'value')
702
+ functionCode += `
703
+ if (arrayLength > 0) {
704
+ const value = obj[0]
705
+ ${code}
706
+ for (let i = 1; i < arrayLength; i++) {
707
+ json += JSON_STR_COMMA
708
+ const value = obj[i]
709
+ ${code}
710
+ }
711
+ }`
712
+ }
713
+
714
+ functionCode += `
715
+ return json + JSON_STR_END_ARRAY
716
+ }`
717
+
718
+ context.functions.push(functionCode)
719
+ return `json += ${functionName}(${input})`
720
+ }
721
+
722
+ context.buildingSet.add(schema)
723
+ const safeSchemaRef = getSafeSchemaRef(context, location)
724
+ const objVar = `obj_${context.uid++}`
725
+ let inlinedCode = `
726
+ const ${objVar} = ${input}
727
+ if (${objVar} === null) {
728
+ json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'}
729
+ } else if (!Array.isArray(${objVar})) {
730
+ throw new TypeError(\`The value of '${safeSchemaRef}' does not match schema definition.\`)
731
+ } else {
732
+ const arrayLength_${objVar} = ${objVar}.length
733
+ `
734
+
735
+ if (!schema.additionalItems && Array.isArray(itemsSchema)) {
736
+ inlinedCode += `
737
+ if (arrayLength_${objVar} > ${itemsSchema.length}) {
738
+ throw new Error(\`Item at ${itemsSchema.length} does not match schema definition.\`)
739
+ }
740
+ `
565
741
  }
566
742
 
567
743
  if (largeArrayMechanism === 'json-stringify') {
568
- functionCode += `if (arrayLength >= ${largeArraySize}) return JSON.stringify(obj)\n`
744
+ inlinedCode += `if (arrayLength_${objVar} >= ${largeArraySize}) json += JSON.stringify(${objVar})\n else {`
569
745
  }
570
746
 
571
- functionCode += `
572
- const arrayEnd = arrayLength - 1
573
- let value
574
- let json = ''
747
+ inlinedCode += `
748
+ json += JSON_STR_BEGIN_ARRAY
575
749
  `
576
750
 
577
751
  if (Array.isArray(itemsSchema)) {
578
- for (let i = 0; i < itemsSchema.length; i++) {
579
- const item = itemsSchema[i]
580
- functionCode += `value = obj[${i}]`
581
- const tmpRes = buildValue(context, itemsLocation.getPropertyLocation(i), 'value')
582
- functionCode += `
583
- if (${i} < arrayLength) {
584
- if (${buildArrayTypeCondition(item.type, 'value')}) {
752
+ const localUid = context.uid++
753
+ inlinedCode += `let addComma_${localUid} = false\n`
754
+ for (let i = 0, itemsSchemaLength = itemsSchema.length; i < itemsSchemaLength; i++) {
755
+ let item = itemsSchema[i]
756
+ let itemLocation = itemsLocation.getPropertyLocation(i)
757
+ if (itemLocation.schema.$ref) {
758
+ itemLocation = resolveRef(context, itemLocation)
759
+ item = itemLocation.schema
760
+ }
761
+ const value = `value_${i}_${context.uid++}`
762
+ inlinedCode += `const ${value} = ${objVar}[${i}]`
763
+ const tmpRes = buildValue(context, itemLocation, value)
764
+ inlinedCode += `
765
+ if (${i} < arrayLength_${objVar}) {
766
+ if (${buildArrayTypeCondition(item.type, value)}) {
767
+ !addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)
585
768
  ${tmpRes}
586
- if (${i} < arrayEnd) {
587
- json += JSON_STR_COMMA
588
- }
589
769
  } else {
590
770
  throw new Error(\`Item at ${i} does not match schema definition.\`)
591
771
  }
@@ -594,66 +774,70 @@ function buildArray (context, location) {
594
774
  }
595
775
 
596
776
  if (schema.additionalItems) {
597
- functionCode += `
598
- for (let i = ${itemsSchema.length}; i < arrayLength; i++) {
599
- value = obj[i]
600
- json += JSON.stringify(value)
601
- if (i < arrayEnd) {
602
- json += JSON_STR_COMMA
603
- }
777
+ inlinedCode += `
778
+ for (let i = ${itemsSchema.length}; i < arrayLength_${objVar}; i++) {
779
+ !addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)
780
+ json += JSON.stringify(${objVar}[i])
604
781
  }`
605
782
  }
606
783
  } else {
607
784
  const code = buildValue(context, itemsLocation, 'value')
608
- functionCode += `
609
- for (let i = 0; i < arrayLength; i++) {
610
- value = obj[i]
785
+ inlinedCode += `
786
+ if (arrayLength_${objVar} > 0) {
787
+ const value = ${objVar}[0]
611
788
  ${code}
612
- if (i < arrayEnd) {
789
+ for (let i = 1; i < arrayLength_${objVar}; i++) {
613
790
  json += JSON_STR_COMMA
791
+ const value = ${objVar}[i]
792
+ ${code}
614
793
  }
615
794
  }`
616
795
  }
617
796
 
618
- functionCode += `
619
- return JSON_STR_BEGIN_ARRAY + json + JSON_STR_END_ARRAY
620
- }`
797
+ inlinedCode += `
798
+ json += JSON_STR_END_ARRAY
799
+ `
621
800
 
622
- context.functions.push(functionCode)
623
- return functionName
801
+ if (largeArrayMechanism === 'json-stringify') {
802
+ inlinedCode += '}'
803
+ }
804
+
805
+ inlinedCode += '}'
806
+ context.buildingSet.delete(schema)
807
+ return inlinedCode
624
808
  }
625
809
 
626
810
  function buildArrayTypeCondition (type, accessor) {
627
811
  let condition
628
812
  switch (type) {
629
813
  case 'null':
630
- condition = 'value === null'
814
+ condition = `${accessor} === null`
631
815
  break
632
816
  case 'string':
633
- condition = `typeof value === 'string' ||
634
- value === null ||
635
- value instanceof Date ||
636
- value instanceof RegExp ||
817
+ condition = `typeof ${accessor} === 'string' ||
818
+ ${accessor} === null ||
819
+ ${accessor} instanceof Date ||
820
+ ${accessor} instanceof RegExp ||
637
821
  (
638
- typeof value === "object" &&
639
- typeof value.toString === "function" &&
640
- value.toString !== Object.prototype.toString
822
+ typeof ${accessor} === "object" &&
823
+ typeof ${accessor}.toString === "function" &&
824
+ ${accessor}.toString !== Object.prototype.toString
641
825
  )`
642
826
  break
643
827
  case 'integer':
644
- condition = 'Number.isInteger(value)'
828
+ condition = `Number.isInteger(${accessor})`
645
829
  break
646
830
  case 'number':
647
- condition = 'Number.isFinite(value)'
831
+ condition = `Number.isFinite(${accessor})`
648
832
  break
649
833
  case 'boolean':
650
- condition = 'typeof value === \'boolean\''
834
+ condition = `typeof ${accessor} === 'boolean'`
651
835
  break
652
836
  case 'object':
653
- condition = 'value && typeof value === \'object\' && value.constructor === Object'
837
+ condition = `${accessor} && typeof ${accessor} === 'object' && ${accessor}.constructor === Object`
654
838
  break
655
839
  case 'array':
656
- condition = 'Array.isArray(value)'
840
+ condition = `Array.isArray(${accessor})`
657
841
  break
658
842
  default:
659
843
  if (Array.isArray(type)) {
@@ -684,8 +868,9 @@ function buildMultiTypeSerializer (context, location, input) {
684
868
  switch (type) {
685
869
  case 'null':
686
870
  code += `
687
- ${statement} (${input} === null)
871
+ ${statement} (${input} === null) {
688
872
  ${nestedResult}
873
+ }
689
874
  `
690
875
  break
691
876
  case 'string': {
@@ -700,40 +885,40 @@ function buildMultiTypeSerializer (context, location, input) {
700
885
  typeof ${input}.toString === "function" &&
701
886
  ${input}.toString !== Object.prototype.toString
702
887
  )
703
- )
888
+ ) {
704
889
  ${nestedResult}
890
+ }
705
891
  `
706
892
  break
707
893
  }
708
894
  case 'array': {
709
895
  code += `
710
- ${statement}(Array.isArray(${input}))
896
+ ${statement}(Array.isArray(${input})) {
711
897
  ${nestedResult}
898
+ }
712
899
  `
713
900
  break
714
901
  }
715
902
  case 'integer': {
716
903
  code += `
717
- ${statement}(Number.isInteger(${input}) || ${input} === null)
904
+ ${statement}(Number.isInteger(${input}) || ${input} === null) {
718
905
  ${nestedResult}
906
+ }
719
907
  `
720
908
  break
721
909
  }
722
910
  default: {
723
911
  code += `
724
- ${statement}(typeof ${input} === "${type}" || ${input} === null)
912
+ ${statement}(typeof ${input} === "${type}" || ${input} === null) {
725
913
  ${nestedResult}
914
+ }
726
915
  `
727
916
  break
728
917
  }
729
918
  }
730
919
  })
731
- let schemaRef = location.getSchemaRef()
732
- if (schemaRef.startsWith(context.rootSchemaId)) {
733
- schemaRef = schemaRef.replace(context.rootSchemaId, '')
734
- }
735
920
  code += `
736
- else throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`)
921
+ else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`)
737
922
  `
738
923
 
739
924
  return code
@@ -779,12 +964,10 @@ function buildSingleTypeSerializer (context, location, input) {
779
964
  case 'boolean':
780
965
  return `json += asBoolean(${input})`
781
966
  case 'object': {
782
- const funcName = buildObject(context, location)
783
- return `json += ${funcName}(${input})`
967
+ return buildObject(context, location, input)
784
968
  }
785
969
  case 'array': {
786
- const funcName = buildArray(context, location)
787
- return `json += ${funcName}(${input})`
970
+ return buildArray(context, location, input)
788
971
  }
789
972
  case undefined:
790
973
  return `json += JSON.stringify(${input})`
@@ -793,6 +976,92 @@ function buildSingleTypeSerializer (context, location, input) {
793
976
  }
794
977
  }
795
978
 
979
+ function detectRecursiveSchemas (context, location) {
980
+ const pathStack = new Set()
981
+ function traverse (location) {
982
+ const schema = location.schema
983
+ if (typeof schema !== 'object' || schema === null) return
984
+
985
+ const schemaId = location.schemaId || ''
986
+ const jsonPointer = location.jsonPointer || ''
987
+ const fullPath = `${schemaId}#${jsonPointer}`
988
+
989
+ if (pathStack.has(fullPath)) {
990
+ // Mark all nodes in the current path that are part of the cycle
991
+ let inCycle = false
992
+ for (const p of pathStack) {
993
+ if (p === fullPath) inCycle = true
994
+ if (inCycle) context.recursivePaths.add(p)
995
+ }
996
+ context.recursivePaths.add(fullPath)
997
+ return
998
+ }
999
+ pathStack.add(fullPath)
1000
+
1001
+ if (schema.$ref) {
1002
+ try {
1003
+ const res = resolveRef(context, location)
1004
+ traverse(res)
1005
+ } catch (err) {
1006
+ // Validation will handle missing refs later
1007
+ }
1008
+ }
1009
+
1010
+ if (schema.properties) {
1011
+ const propertiesLocation = location.getPropertyLocation('properties')
1012
+ for (const key in schema.properties) {
1013
+ traverse(propertiesLocation.getPropertyLocation(key))
1014
+ }
1015
+ }
1016
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
1017
+ traverse(location.getPropertyLocation('additionalProperties'))
1018
+ }
1019
+ if (schema.patternProperties) {
1020
+ const patternPropertiesLocation = location.getPropertyLocation('patternProperties')
1021
+ for (const key in schema.patternProperties) {
1022
+ traverse(patternPropertiesLocation.getPropertyLocation(key))
1023
+ }
1024
+ }
1025
+ if (schema.items) {
1026
+ const itemsLocation = location.getPropertyLocation('items')
1027
+ if (Array.isArray(schema.items)) {
1028
+ for (let i = 0; i < schema.items.length; i++) {
1029
+ traverse(itemsLocation.getPropertyLocation(i))
1030
+ }
1031
+ } else {
1032
+ traverse(itemsLocation)
1033
+ }
1034
+ }
1035
+ if (schema.additionalItems && typeof schema.additionalItems === 'object') {
1036
+ traverse(location.getPropertyLocation('additionalItems'))
1037
+ }
1038
+
1039
+ if (schema.oneOf) {
1040
+ const oneOfLocation = location.getPropertyLocation('oneOf')
1041
+ for (let i = 0; i < schema.oneOf.length; i++) {
1042
+ traverse(oneOfLocation.getPropertyLocation(i))
1043
+ }
1044
+ }
1045
+ if (schema.anyOf) {
1046
+ const anyOfLocation = location.getPropertyLocation('anyOf')
1047
+ for (let i = 0; i < schema.anyOf.length; i++) {
1048
+ traverse(anyOfLocation.getPropertyLocation(i))
1049
+ }
1050
+ }
1051
+ if (schema.allOf) {
1052
+ const allOfLocation = location.getPropertyLocation('allOf')
1053
+ for (let i = 0; i < schema.allOf.length; i++) {
1054
+ traverse(allOfLocation.getPropertyLocation(i))
1055
+ }
1056
+ }
1057
+ if (schema.then) traverse(location.getPropertyLocation('then'))
1058
+ if (schema.else) traverse(location.getPropertyLocation('else'))
1059
+
1060
+ pathStack.delete(fullPath)
1061
+ }
1062
+ traverse(location)
1063
+ }
1064
+
796
1065
  function buildConstSerializer (location, input) {
797
1066
  const schema = location.schema
798
1067
  const type = schema.type
@@ -842,7 +1111,7 @@ function buildAllOf (context, location, input) {
842
1111
  ]
843
1112
 
844
1113
  const allOfsLocation = location.getPropertyLocation('allOf')
845
- for (let i = 0; i < allOf.length; i++) {
1114
+ for (let i = 0, allOfLength = allOf.length; i < allOfLength; i++) {
846
1115
  locations.push(allOfsLocation.getPropertyLocation(i))
847
1116
  }
848
1117
 
@@ -867,7 +1136,7 @@ function buildOneOf (context, location, input) {
867
1136
 
868
1137
  let code = ''
869
1138
 
870
- for (let index = 0; index < oneOfs.length; index++) {
1139
+ for (let index = 0, oneOfsLength = oneOfs.length; index < oneOfsLength; index++) {
871
1140
  const optionLocation = oneOfsLocation.getPropertyLocation(index)
872
1141
  const optionSchema = optionLocation.schema
873
1142
 
@@ -887,19 +1156,16 @@ function buildOneOf (context, location, input) {
887
1156
 
888
1157
  const nestedResult = buildValue(context, mergedLocation, input)
889
1158
  const schemaRef = optionLocation.getSchemaRef()
1159
+
890
1160
  code += `
891
- ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input}))
1161
+ ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) {
892
1162
  ${nestedResult}
1163
+ }
893
1164
  `
894
1165
  }
895
1166
 
896
- let schemaRef = location.getSchemaRef()
897
- if (schemaRef.startsWith(context.rootSchemaId)) {
898
- schemaRef = schemaRef.replace(context.rootSchemaId, '')
899
- }
900
-
901
1167
  code += `
902
- else throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`)
1168
+ else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`)
903
1169
  `
904
1170
 
905
1171
  return code
package/lib/validator.js CHANGED
@@ -21,8 +21,8 @@ class Validator {
21
21
  keyword: 'fjs_type',
22
22
  type: 'object',
23
23
  errors: false,
24
- validate: (_type, date) => {
25
- return date instanceof Date
24
+ validate: (_type, data) => {
25
+ return data && typeof data.toJSON === 'function'
26
26
  }
27
27
  })
28
28
 
@@ -51,8 +51,10 @@ class Validator {
51
51
  return this.ajv.validate(schemaRef, data)
52
52
  }
53
53
 
54
- // Ajv does not support js date format. In order to properly validate objects containing a date,
55
- // it needs to replace all occurrences of the string date format with a custom keyword fjs_type.
54
+ // Ajv does not natively support JavaScript objects like Date or other types
55
+ // that rely on a custom .toJSON() representation. To properly validate schemas
56
+ // that may contain such objects (e.g. Date, ObjectId, etc.), we replace all
57
+ // occurrences of the string type with a custom keyword fjs_type
56
58
  // (see https://github.com/fastify/fast-json-stringify/pull/441)
57
59
  convertSchemaToAjvFormat (schema) {
58
60
  if (schema === null) return
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "6.1.1",
3
+ "version": "6.3.0",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
8
8
  "scripts": {
9
- "bench": "node ./benchmark/bench.js",
9
+ "bench": "node --expose-gc ./benchmark/bench.js",
10
10
  "bench:cmp": "node ./benchmark/bench-cmp-branch.js",
11
11
  "bench:cmp:ci": "node ./benchmark/bench-cmp-branch.js --ci",
12
- "benchmark": "node ./benchmark/bench-cmp-lib.js",
12
+ "benchmark": "node --expose-gc ./benchmark/bench-cmp-lib.js",
13
13
  "lint": "eslint",
14
14
  "lint:fix": "eslint --fix",
15
15
  "test:typescript": "tsd",
@@ -62,19 +62,20 @@
62
62
  }
63
63
  ],
64
64
  "devDependencies": {
65
- "@sinclair/typebox": "^0.34.3",
66
- "benchmark": "^2.1.4",
67
- "c8": "^10.1.2",
65
+ "@sinclair/typebox": "^0.34.48",
66
+ "c8": "^10.1.3",
68
67
  "cli-select": "^1.1.2",
69
68
  "compile-json-stringify": "^0.1.2",
70
- "eslint": "^9.17.0",
69
+ "eslint": "^9.39.2",
71
70
  "fast-json-stringify": ".",
72
71
  "is-my-json-valid": "^2.20.6",
73
- "json-accelerator": "^0.0.2",
74
- "neostandard": "^0.12.0",
75
- "simple-git": "^3.23.0",
76
- "tsd": "^0.32.0",
77
- "webpack": "^5.90.3"
72
+ "json-accelerator": "^0.1.7",
73
+ "neostandard": "^0.12.2",
74
+ "simple-git": "^3.30.0",
75
+ "tinybench": "^6.0.0",
76
+ "tsd": "^0.33.0",
77
+ "typescript": "^5.9.3",
78
+ "webpack": "^5.104.1"
78
79
  },
79
80
  "dependencies": {
80
81
  "@fastify/merge-json-schemas": "^0.2.0",
@@ -61,6 +61,34 @@ buildTest({
61
61
  dates: [new Date(1), new Date(2)]
62
62
  })
63
63
 
64
+ buildTest({
65
+ title: 'dates tuple $ref',
66
+ definitions: {
67
+ dateTime: {
68
+ type: 'string',
69
+ format: 'date-time'
70
+ }
71
+ },
72
+ type: 'object',
73
+ properties: {
74
+ dates: {
75
+ type: 'array',
76
+ minItems: 2,
77
+ maxItems: 2,
78
+ items: [
79
+ {
80
+ $ref: '#/definitions/dateTime'
81
+ },
82
+ {
83
+ $ref: '#/definitions/dateTime'
84
+ }
85
+ ]
86
+ }
87
+ }
88
+ }, {
89
+ dates: [new Date(1), new Date(2)]
90
+ })
91
+
64
92
  buildTest({
65
93
  title: 'string array',
66
94
  type: 'object',
@@ -0,0 +1,57 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const build = require('..')
5
+
6
+ test('additionalProperties: false', (t) => {
7
+ t.plan(1)
8
+ const stringify = build({
9
+ title: 'additionalProperties',
10
+ type: 'object',
11
+ properties: {
12
+ foo: {
13
+ type: 'string'
14
+ }
15
+ },
16
+ additionalProperties: false
17
+ })
18
+
19
+ const obj = { foo: 'a', bar: 'b', baz: 'c' }
20
+ t.assert.equal(stringify(obj), '{"foo":"a"}')
21
+ })
22
+
23
+ test('additionalProperties: {}', (t) => {
24
+ t.plan(1)
25
+ const stringify = build({
26
+ title: 'additionalProperties',
27
+ type: 'object',
28
+ properties: {
29
+ foo: {
30
+ type: 'string'
31
+ }
32
+ },
33
+ additionalProperties: {}
34
+ })
35
+
36
+ const obj = { foo: 'a', bar: 'b', baz: 'c' }
37
+ t.assert.equal(stringify(obj), '{"foo":"a","bar":"b","baz":"c"}')
38
+ })
39
+
40
+ test('additionalProperties: {type: string}', (t) => {
41
+ t.plan(1)
42
+ const stringify = build({
43
+ title: 'additionalProperties',
44
+ type: 'object',
45
+ properties: {
46
+ foo: {
47
+ type: 'string'
48
+ }
49
+ },
50
+ additionalProperties: {
51
+ type: 'string'
52
+ }
53
+ })
54
+
55
+ const obj = { foo: 'a', bar: 'b', baz: 'c' }
56
+ t.assert.equal(stringify(obj), '{"foo":"a","bar":"b","baz":"c"}')
57
+ })