fast-json-stringify 7.1.5 → 7.2.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.
- package/README.md +24 -23
- package/index.js +12 -3
- package/package.json +1 -1
- package/test/string-array-cutoff.test.js +62 -0
- package/types/index.d.ts +2 -1
package/README.md
CHANGED
|
@@ -625,13 +625,10 @@ In order to improve that the user can set the `largeArrayMechanism` and
|
|
|
625
625
|
|
|
626
626
|
- `default` - This option keeps the full feature set of this lib: every element
|
|
627
627
|
is still coerced to the type declared in `items`, and type errors are still
|
|
628
|
-
thrown. Arrays
|
|
629
|
-
`
|
|
630
|
-
|
|
631
|
-
|
|
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.
|
|
628
|
+
thrown. Arrays of plain primitives (`string`, `number`, `integer`) use a
|
|
629
|
+
copy-then-`JSON.stringify` strategy with their own length cutoffs, see
|
|
630
|
+
[Primitive arrays](#primitive-arrays) below; `largeArraySize` does not affect
|
|
631
|
+
them.
|
|
635
632
|
- `json-stringify` - This option will remove support for schema validation
|
|
636
633
|
within **large arrays** completely. By doing so the overhead previously
|
|
637
634
|
mentioned is nulled, greatly improving execution time. Mind there's no change
|
|
@@ -646,27 +643,31 @@ integer-like values, such as:
|
|
|
646
643
|
- `'2e4'` - _note this will be converted to `2`, not `20000`_
|
|
647
644
|
- `1.5` - _note this will be converted to `1`_
|
|
648
645
|
|
|
649
|
-
|
|
646
|
+
<a name="primitive-arrays"></a>
|
|
647
|
+
##### Primitive arrays
|
|
650
648
|
|
|
651
649
|
With `largeArrayMechanism: 'default'`, arrays whose `items` schema is a plain
|
|
652
|
-
`{ type: 'number' }` or `{ type: 'integer' }` (no
|
|
653
|
-
or combinator keyword
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
650
|
+
`{ type: 'string' }`, `{ type: 'number' }` or `{ type: 'integer' }` (no
|
|
651
|
+
`format`, `nullable`, `const`, or combinator keyword; a `$ref` to a plain
|
|
652
|
+
string schema also qualifies, a `$ref` for `number`/`integer` does not) are
|
|
653
|
+
serialized by copying the elements into a fresh array and calling the native
|
|
654
|
+
`JSON.stringify` on the copy, which is several times faster than appending one
|
|
655
|
+
element at a time. This happens at or above a length cutoff that does not
|
|
656
|
+
depend on `largeArraySize`. The cutoffs are the array lengths above which the
|
|
657
|
+
copy beats the per-element loop, and they depend on the V8 version:
|
|
657
658
|
|
|
658
|
-
| Node.js | `number` | `integer` |
|
|
659
|
-
|
|
660
|
-
| 22 and later | 128 elements | 512 elements |
|
|
661
|
-
| 20 | 300000 elements | 300000 elements |
|
|
659
|
+
| Node.js | `string` | `number` | `integer` |
|
|
660
|
+
|---|---:|---:|---:|
|
|
661
|
+
| 22 and later | 8 elements | 128 elements | 512 elements |
|
|
662
|
+
| 20 | 128 elements | 300000 elements | 300000 elements |
|
|
662
663
|
|
|
663
664
|
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
|
|
665
|
-
(or not
|
|
666
|
-
go through the regular per-element coercion
|
|
667
|
-
|
|
668
|
-
byte-identical either way. Arrays of
|
|
669
|
-
`items` schema are not affected.
|
|
665
|
+
it, the elements are copied until one is found that is not already of the
|
|
666
|
+
declared type (not a string, not a `number`, or not an integer for `integer`);
|
|
667
|
+
that element and the ones after it go through the regular per-element coercion
|
|
668
|
+
and validation (`null` still becomes `""` or `0`, `rounding` still applies,
|
|
669
|
+
`NaN` still throws). The output is byte-identical either way. Arrays of
|
|
670
|
+
`boolean`, `null`, objects, or any other `items` schema are not affected.
|
|
670
671
|
|
|
671
672
|
In [standalone mode](#standalone) the cutoffs are baked into the generated
|
|
672
673
|
code, so the generated module inherits the cutoffs of the Node.js version that
|
package/index.js
CHANGED
|
@@ -28,12 +28,21 @@ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
|
|
|
28
28
|
// Node 20: JSON.stringify only beats the loop for every element kind from
|
|
29
29
|
// ~3e5 (1e5 is machine-dependent), so both kinds wait for 3e5 there.
|
|
30
30
|
//
|
|
31
|
+
// Strings: the generated loop escapes each element in JS (asString), which
|
|
32
|
+
// is far slower than the native serializer, so the copy wins almost
|
|
33
|
+
// immediately. Node 22+: from 2 elements for every string length and content
|
|
34
|
+
// (2-6x faster from 8 elements on). Node 20: from 32 elements for short
|
|
35
|
+
// strings, but only from 128 for long (~120 chars) strings without escapes,
|
|
36
|
+
// so Node 20 waits for 128. Measured on Node 20, 22, 24 and 26 (arm64) with
|
|
37
|
+
// 8, 40 and 120 char strings, with and without characters to escape.
|
|
38
|
+
//
|
|
31
39
|
// Booleans never win and keep the loop. The array lookup instead of a
|
|
32
40
|
// ternary keeps both branches covered on every Node.
|
|
33
41
|
// TODO: drop the Node 20 tier (and this version check) once Node 20 is no
|
|
34
42
|
// longer supported.
|
|
35
43
|
const nodeMajor = Number.parseInt(process.versions.node, 10)
|
|
36
|
-
const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5 }, { integer: 512, number: 128 }][+(nodeMajor >= 22)]
|
|
44
|
+
const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5, string: 128 }, { integer: 512, number: 128, string: 8 }][+(nodeMajor >= 22)]
|
|
45
|
+
const STRING_ARRAY_FAST_PATH_MIN_LENGTH = PRIMITIVE_FAST_PATH_MIN_LENGTH.string
|
|
37
46
|
const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
|
|
38
47
|
const NUMBER_ARRAY_FAST_PATH = { condition: 'typeof v === \'number\' && v === v', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.number }
|
|
39
48
|
|
|
@@ -1095,7 +1104,7 @@ function buildArray (context, location, input) {
|
|
|
1095
1104
|
} else if (plainStringFastPath) {
|
|
1096
1105
|
// Copy strings once; the helper resumes ordered coercion on the first miss.
|
|
1097
1106
|
functionCode += `
|
|
1098
|
-
if (typeof arrayLength === 'number' && arrayLength >= ${
|
|
1107
|
+
if (typeof arrayLength === 'number' && arrayLength >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
|
|
1099
1108
|
return asPlainStringArray(obj, arrayLength)
|
|
1100
1109
|
}
|
|
1101
1110
|
`
|
|
@@ -1221,7 +1230,7 @@ function buildArray (context, location, input) {
|
|
|
1221
1230
|
} else if (plainStringFastPath) {
|
|
1222
1231
|
inlinedCode += `
|
|
1223
1232
|
let fjsPlain_${objVar}
|
|
1224
|
-
if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${
|
|
1233
|
+
if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
|
|
1225
1234
|
if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
|
|
1226
1235
|
else {
|
|
1227
1236
|
`
|
package/package.json
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test')
|
|
4
|
+
const build = require('..')
|
|
5
|
+
|
|
6
|
+
// Plain string arrays use their own copy-then-JSON.stringify cutoff, independent
|
|
7
|
+
// of largeArraySize (which only drives largeArrayMechanism: 'json-stringify').
|
|
8
|
+
const nodeMajor = Number.parseInt(process.versions.node, 10)
|
|
9
|
+
const CUTOFF = nodeMajor >= 22 ? 8 : 128
|
|
10
|
+
|
|
11
|
+
const schema = { type: 'array', items: { type: 'string' } }
|
|
12
|
+
const nested = { type: 'object', properties: { tags: schema } }
|
|
13
|
+
const strings = (n) => Array.from({ length: n }, (_, i) => 'item-' + i)
|
|
14
|
+
|
|
15
|
+
test('string array cutoff does not depend on largeArraySize', t => {
|
|
16
|
+
t.plan(2)
|
|
17
|
+
const code = build(schema, { mode: 'debug', largeArraySize: 5000 }).code
|
|
18
|
+
t.assert.ok(code.includes(`arrayLength >= ${CUTOFF} `), 'uses the string cutoff')
|
|
19
|
+
t.assert.ok(!code.includes('arrayLength >= 5000'), 'ignores largeArraySize')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('string arrays at and above the cutoff are byte-identical to JSON.stringify', t => {
|
|
23
|
+
const stringify = build(schema)
|
|
24
|
+
const lengths = [CUTOFF - 1, CUTOFF, CUTOFF + 1, CUTOFF * 10]
|
|
25
|
+
t.plan(lengths.length)
|
|
26
|
+
for (const n of lengths) {
|
|
27
|
+
t.assert.equal(stringify(strings(n)), JSON.stringify(strings(n)))
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('nested string arrays use the same cutoff', t => {
|
|
32
|
+
t.plan(2)
|
|
33
|
+
const code = build(nested, { mode: 'debug', largeArraySize: 5000 }).code
|
|
34
|
+
t.assert.ok(code.includes(`>= ${CUTOFF} `))
|
|
35
|
+
t.assert.equal(build(nested)({ tags: strings(CUTOFF) }), JSON.stringify({ tags: strings(CUTOFF) }))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('escapes and non-string elements above the cutoff keep per-element semantics', t => {
|
|
39
|
+
t.plan(4)
|
|
40
|
+
const stringify = build(schema)
|
|
41
|
+
const withEscapes = strings(CUTOFF).map((s, i) => i % 2 ? s + '"\n\u0000' : s)
|
|
42
|
+
t.assert.equal(stringify(withEscapes), JSON.stringify(withEscapes))
|
|
43
|
+
const mixed = strings(CUTOFF)
|
|
44
|
+
mixed[1] = 42
|
|
45
|
+
mixed[2] = null
|
|
46
|
+
// asString semantics: non-strings are coerced via toString, null becomes ""
|
|
47
|
+
t.assert.equal(stringify(mixed), JSON.stringify(mixed.map((v) => v === null ? '' : String(v))))
|
|
48
|
+
const dates = strings(CUTOFF)
|
|
49
|
+
dates[0] = new Date(0)
|
|
50
|
+
t.assert.equal(stringify(dates), JSON.stringify(dates.map((v) => v instanceof Date ? v.toISOString() : v)))
|
|
51
|
+
const regexps = strings(CUTOFF)
|
|
52
|
+
regexps[3] = /a"b/
|
|
53
|
+
regexps[5] = /c/g
|
|
54
|
+
t.assert.equal(stringify(regexps), JSON.stringify(regexps.map((v) => v instanceof RegExp ? v.source : v)))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('largeArraySize still drives the json-stringify mechanism for strings', t => {
|
|
58
|
+
t.plan(2)
|
|
59
|
+
const code = build(schema, { mode: 'debug', largeArrayMechanism: 'json-stringify', largeArraySize: 5000 }).code
|
|
60
|
+
t.assert.ok(code.includes('arrayLength >= 5000'))
|
|
61
|
+
t.assert.ok(!code.includes('asPlainStringArray(obj'))
|
|
62
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -181,7 +181,8 @@ declare namespace build {
|
|
|
181
181
|
/**
|
|
182
182
|
* Large arrays are defined as arrays containing, by default, `20000`
|
|
183
183
|
* elements or more. That value can be adjusted via the option parameter
|
|
184
|
-
* `largeArraySize`.
|
|
184
|
+
* `largeArraySize`. Only used with `largeArrayMechanism: 'json-stringify'`;
|
|
185
|
+
* arrays of plain primitives have their own, runtime-dependent cutoffs.
|
|
185
186
|
*
|
|
186
187
|
* @default 20000
|
|
187
188
|
*/
|