fast-json-stringify 7.1.0 → 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
 
@@ -1,6 +1,8 @@
1
1
  'use strict'
2
2
 
3
+ const assert = require('node:assert/strict')
3
4
  const { Bench } = require('tinybench')
5
+ const apiResponses = require('./fixtures/api-responses')
4
6
  const suite = new Bench({
5
7
  name: 'Library Comparison Benchmarks',
6
8
  setup: (_task, mode) => {
@@ -173,139 +175,162 @@ for (let i = 0; i < MULTI_ARRAY_LENGTH; i++) {
173
175
  multiArray[i] = obj
174
176
  }
175
177
 
176
- suite.add('fast-json-stringify: creation', function () {
177
- FJS(schema)
178
+ // Some serializers return unflattened strings. Fastify calls Buffer.byteLength()
179
+ // for Content-Length, which triggers flattening; include that cost in every case.
180
+ suite.add('fast-json-stringify: creation and first stringify', function () {
181
+ Buffer.byteLength(FJS(schema)(obj))
178
182
  })
179
- suite.add('compile-json-stringify: creation', function () {
180
- CJS(schemaCJS)
183
+ suite.add('compile-json-stringify: creation and first stringify', function () {
184
+ Buffer.byteLength(CJS(schemaCJS)(obj))
181
185
  })
182
- suite.add('AJV: creation', function () {
183
- ajv.compileSerializer(schemaAJVJTD)
186
+ suite.add('AJV: creation and first stringify', function () {
187
+ Buffer.byteLength(ajv.compileSerializer(schemaAJVJTD)(obj))
184
188
  })
185
- suite.add('json-accelerator: creation', function () {
186
- createAccelerator(schema)
189
+ suite.add('json-accelerator: creation and first stringify', function () {
190
+ Buffer.byteLength(createAccelerator(schema)(obj))
187
191
  })
188
192
 
189
193
  suite.add('JSON.stringify: array', function () {
190
- JSON.stringify(multiArray)
194
+ Buffer.byteLength(JSON.stringify(multiArray))
191
195
  })
192
196
 
193
197
  suite.add('fast-json-stringify [default]: array', function () {
194
- stringifyArrayDefault(multiArray)
198
+ Buffer.byteLength(stringifyArrayDefault(multiArray))
195
199
  })
196
200
 
197
201
  suite.add('json-accelerator: array', function () {
198
- accelArray(multiArray)
202
+ Buffer.byteLength(accelArray(multiArray))
199
203
  })
200
204
 
201
205
  suite.add('fast-json-stringify [json-stringify]: array', function () {
202
- stringifyArrayJSONStringify(multiArray)
206
+ Buffer.byteLength(stringifyArrayJSONStringify(multiArray))
203
207
  })
204
208
 
205
209
  suite.add('compile-json-stringify: array', function () {
206
- CJSStringifyArray(multiArray)
210
+ Buffer.byteLength(CJSStringifyArray(multiArray))
207
211
  })
208
212
 
209
213
  suite.add('AJV: array', function () {
210
- ajvSerializeArray(multiArray)
214
+ Buffer.byteLength(ajvSerializeArray(multiArray))
211
215
  })
212
216
 
213
217
  suite.add('JSON.stringify: large array', function () {
214
- JSON.stringify(largeArray)
218
+ Buffer.byteLength(JSON.stringify(largeArray))
215
219
  })
216
220
 
217
221
  suite.add('fast-json-stringify [default]: large array', function () {
218
- stringifyArrayDefault(largeArray)
222
+ Buffer.byteLength(stringifyArrayDefault(largeArray))
219
223
  })
220
224
 
221
225
  suite.add('fast-json-stringify [json-stringify]: large array', function () {
222
- stringifyArrayJSONStringify(largeArray)
226
+ Buffer.byteLength(stringifyArrayJSONStringify(largeArray))
223
227
  })
224
228
 
225
229
  suite.add('compile-json-stringify: large array', function () {
226
- CJSStringifyArray(largeArray)
230
+ Buffer.byteLength(CJSStringifyArray(largeArray))
227
231
  })
228
232
 
229
233
  suite.add('AJV: large array', function () {
230
- ajvSerializeArray(largeArray)
234
+ Buffer.byteLength(ajvSerializeArray(largeArray))
231
235
  })
232
236
 
233
237
  suite.add('JSON.stringify: long string', function () {
234
- JSON.stringify(str)
238
+ Buffer.byteLength(JSON.stringify(str))
235
239
  })
236
240
 
237
241
  suite.add('fast-json-stringify: long string', function () {
238
- stringifyString(str)
242
+ Buffer.byteLength(stringifyString(str))
239
243
  })
240
244
 
241
245
  suite.add('json-accelerator: long string', function () {
242
- stringifyString(str)
246
+ Buffer.byteLength(stringifyString(str))
243
247
  })
244
248
 
245
249
  suite.add('compile-json-stringify: long string', function () {
246
- CJSStringifyString(str)
250
+ Buffer.byteLength(CJSStringifyString(str))
247
251
  })
248
252
 
249
253
  suite.add('AJV: long string', function () {
250
- ajvSerializeString(str)
254
+ Buffer.byteLength(ajvSerializeString(str))
251
255
  })
252
256
 
253
257
  suite.add('JSON.stringify: short string', function () {
254
- JSON.stringify('hello world')
258
+ Buffer.byteLength(JSON.stringify('hello world'))
255
259
  })
256
260
 
257
261
  suite.add('fast-json-stringify: short string', function () {
258
- stringifyString('hello world')
262
+ Buffer.byteLength(stringifyString('hello world'))
259
263
  })
260
264
 
261
265
  suite.add('json-accelerator: short string', function () {
262
- accelString('hello world')
266
+ Buffer.byteLength(accelString('hello world'))
263
267
  })
264
268
 
265
269
  suite.add('compile-json-stringify: short string', function () {
266
- CJSStringifyString('hello world')
270
+ Buffer.byteLength(CJSStringifyString('hello world'))
267
271
  })
268
272
 
269
273
  suite.add('AJV: short string', function () {
270
- ajvSerializeString('hello world')
274
+ Buffer.byteLength(ajvSerializeString('hello world'))
271
275
  })
272
276
 
273
277
  suite.add('JSON.stringify: obj', function () {
274
- JSON.stringify(obj)
278
+ Buffer.byteLength(JSON.stringify(obj))
275
279
  })
276
280
 
277
281
  suite.add('fast-json-stringify: obj', function () {
278
- stringify(obj)
282
+ Buffer.byteLength(stringify(obj))
279
283
  })
280
284
 
281
285
  suite.add('json-accelerator: obj', function () {
282
- accelStringify(obj)
286
+ Buffer.byteLength(accelStringify(obj))
283
287
  })
284
288
 
285
289
  suite.add('compile-json-stringify: obj', function () {
286
- CJSStringify(obj)
290
+ Buffer.byteLength(CJSStringify(obj))
287
291
  })
288
292
 
289
293
  suite.add('AJV: obj', function () {
290
- ajvSerialize(obj)
294
+ Buffer.byteLength(ajvSerialize(obj))
291
295
  })
292
296
 
293
297
  suite.add('JSON.stringify: date', function () {
294
- JSON.stringify(date)
298
+ Buffer.byteLength(JSON.stringify(date))
295
299
  })
296
300
 
297
301
  suite.add('fast-json-stringify: date', function () {
298
- stringifyDate(date)
302
+ Buffer.byteLength(stringifyDate(date))
299
303
  })
300
304
 
301
305
  suite.add('json-accelerate: date', function () {
302
- accelDate(date)
306
+ Buffer.byteLength(accelDate(date))
303
307
  })
304
308
 
305
309
  suite.add('compile-json-stringify: date', function () {
306
- CJSStringifyDate(date)
310
+ Buffer.byteLength(CJSStringifyDate(date))
307
311
  })
308
312
 
313
+ for (const { name, schema: responseSchema, input } of apiResponses) {
314
+ const stringifyResponse = FJS(responseSchema)
315
+ // Check outside the timed loop that the schema preserves the complete response.
316
+ assert.deepStrictEqual(JSON.parse(stringifyResponse(input)), JSON.parse(JSON.stringify(input)))
317
+
318
+ suite.add(`JSON.stringify: ${name}`, function () {
319
+ Buffer.byteLength(JSON.stringify(input))
320
+ })
321
+ suite.add(`fast-json-stringify: ${name}`, function () {
322
+ Buffer.byteLength(stringifyResponse(input))
323
+ })
324
+ }
325
+
326
+ if (process.argv.includes('--fjs-vs-json')) {
327
+ for (const task of suite.tasks) {
328
+ if (!task.name.startsWith('fast-json-stringify') && !task.name.startsWith('JSON.stringify:')) {
329
+ suite.remove(task.name)
330
+ }
331
+ }
332
+ }
333
+
309
334
  suite.run().then(() => {
310
335
  const results = suite.tasks.map(task => ({
311
336
  name: task.name,
@@ -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 })
@@ -18,10 +18,12 @@ const FJS = require('..')
18
18
  const stringify = benchmark.compile ? null : FJS(benchmark.schema, benchmark.options)
19
19
 
20
20
  bench.add(benchmark.name, () => {
21
+ // Some serializers return unflattened strings. Fastify calls Buffer.byteLength()
22
+ // for Content-Length, which triggers flattening; include that cost here too.
21
23
  if (benchmark.compile) {
22
- FJS(benchmark.schema, benchmark.options)(benchmark.input)
24
+ Buffer.byteLength(FJS(benchmark.schema, benchmark.options)(benchmark.input))
23
25
  } else {
24
- stringify(benchmark.input)
26
+ Buffer.byteLength(stringify(benchmark.input))
25
27
  }
26
28
  }).run().then(() => {
27
29
  const task = bench.tasks[0]
@@ -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 }