fast-json-stringify 7.1.2 → 7.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,7 +127,7 @@ const stringify = fastJson(mySchema, {
127
127
  ```
128
128
 
129
129
  - `schema`: external schemas references by $ref property. [More details](#ref)
130
- - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof)
130
+ - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof) Schema strictness defaults to `false`; pass `ajv: { strictSchema: true }` to reject unknown schema keywords or `ajv: { strictSchema: 'log' }` to log warnings. This applies only to schema paths compiled by Ajv, not to every schema processed by fast-json-stringify.
131
131
  - `rounding`: setup how the `integer` types will be rounded when not integers. [More details](#integer)
132
132
  - `maxDepth`: maximum number of nested schema levels allowed during compilation.
133
133
  Defaults to `100` and may be set from `0` to `100`. Schemas that exceed this limit
package/index.js CHANGED
@@ -13,7 +13,7 @@ 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
16
+ // Minimum array lengths at which copying into a plain array and
17
17
  // calling native JSON.stringify beats the generated per-item loop. Measured
18
18
  // against main with tinybench (alternating rounds, median ops/s, output
19
19
  // consumed with Buffer.byteLength) on Node 20, 22, 24 and 26 x64.
@@ -1055,7 +1055,10 @@ function buildArray (context, location, input) {
1055
1055
  // Copying small arrays costs more than the existing loop.
1056
1056
  if (typeof arrayLength === 'number' && arrayLength >= ${fastPath.minLength} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
1057
1057
  const copy = new Array(arrayLength)
1058
- Object.setPrototypeOf(copy, null)
1058
+ // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires
1059
+ // the standard Array.prototype; a null prototype halves its throughput.
1060
+ // Only detach the prototype when a toJSON hook would otherwise be called.
1061
+ if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null)
1059
1062
  for (let i = 0; i < arrayLength; i++) {
1060
1063
  const v = obj[i]
1061
1064
  if (!(${fastPath.condition})) {
package/lib/serializer.js CHANGED
@@ -140,7 +140,10 @@ module.exports = class Serializer {
140
140
  // matching buildSingleTypeSerializer without rereading getters.
141
141
  asPlainStringArray (arr, len = arr.length) {
142
142
  const copy = new Array(len)
143
- Object.setPrototypeOf(copy, null)
143
+ // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires the
144
+ // standard Array.prototype; a null prototype halves its throughput. Only
145
+ // detach the prototype when a toJSON hook would otherwise be called.
146
+ if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null)
144
147
  for (let i = 0; i < len; i++) {
145
148
  const value = arr[i]
146
149
  if (typeof value !== 'string') {
package/lib/validator.js CHANGED
@@ -15,7 +15,7 @@ class Validator {
15
15
  this.ajv = new Ajv({
16
16
  ...ajvOptions,
17
17
  ...codeOptions,
18
- strictSchema: false,
18
+ strictSchema: ajvOptions?.strictSchema ?? false,
19
19
  validateSchema: false,
20
20
  allowUnionTypes: true,
21
21
  uriResolver: fastUri
@@ -50,7 +50,7 @@ class Validator {
50
50
  const ajvSchema = clone(schema)
51
51
  this.convertSchemaToAjvFormat(ajvSchema)
52
52
  this.ajv.addSchema(ajvSchema, schemaKey)
53
- this._ajvSchemas[schemaKey] = schema
53
+ this._ajvSchemas[schemaKey] = ajvSchema
54
54
  }
55
55
  }
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.1.2",
3
+ "version": "7.1.3",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const build = require('..')
5
+
6
+ // The copy-then-JSON.stringify fast path for primitive arrays must produce the
7
+ // same output whether or not Array.prototype.toJSON has been patched, and must
8
+ // never invoke such a hook.
9
+
10
+ const numbers = Array.from({ length: 1000 }, (_, i) => i * 1.37)
11
+ const integers = Array.from({ length: 1000 }, (_, i) => i)
12
+ const strings = Array.from({ length: 1000 }, (_, i) => 'item-' + i)
13
+
14
+ const cases = [
15
+ ['number', { type: 'array', items: { type: 'number' } }, numbers, {}],
16
+ ['integer', { type: 'array', items: { type: 'integer' } }, integers, {}],
17
+ ['string', { type: 'array', items: { type: 'string' } }, strings, { largeArraySize: 100 }]
18
+ ]
19
+
20
+ for (const [name, schema, data, options] of cases) {
21
+ test(`${name} array fast path with pristine Array.prototype`, t => {
22
+ t.plan(2)
23
+ t.assert.equal(Array.prototype.toJSON, undefined)
24
+ const stringify = build(schema, options)
25
+ t.assert.equal(stringify(data), JSON.stringify(data))
26
+ })
27
+
28
+ test(`${name} array fast path ignores a patched Array.prototype.toJSON`, t => {
29
+ t.plan(2)
30
+ const expected = JSON.stringify(data)
31
+ let called = 0
32
+ // eslint-disable-next-line no-extend-native
33
+ Array.prototype.toJSON = function () { called++; return 'hooked' }
34
+ t.after(() => { delete Array.prototype.toJSON })
35
+ const stringify = build(schema, options)
36
+ t.assert.equal(stringify(data), expected)
37
+ t.assert.equal(called, 0)
38
+ })
39
+
40
+ test(`${name} array fast path resumes per-item coercion after a miss, with patched toJSON`, t => {
41
+ t.plan(1)
42
+ const mixed = data.slice()
43
+ mixed[500] = name === 'string' ? 42 : '7'
44
+ const stringify = build(schema, options)
45
+ const expected = stringify(mixed)
46
+ // eslint-disable-next-line no-extend-native
47
+ Array.prototype.toJSON = function () { return 'hooked' }
48
+ t.after(() => { delete Array.prototype.toJSON })
49
+ t.assert.equal(build(schema, options)(mixed), expected)
50
+ })
51
+ }
@@ -318,6 +318,38 @@ test('inline validators with external oneOf refs and toJSON values', async (t) =
318
318
  t.assert.throws(() => stringify({ value: true }), /does not match schema definition/)
319
319
  })
320
320
 
321
+ test('restored validator handles toJSON values', async (t) => {
322
+ t.plan(3)
323
+
324
+ after(async () => {
325
+ await fs.promises.rm(destination, { force: true })
326
+ })
327
+
328
+ const code = fjs({
329
+ type: 'object',
330
+ properties: {
331
+ value: {
332
+ oneOf: [
333
+ { type: 'string', format: 'date-time' },
334
+ { type: 'integer' }
335
+ ]
336
+ }
337
+ }
338
+ }, { mode: 'standalone', inlineValidators: false })
339
+
340
+ const destination = path.resolve(tmpDir, 'standalone-restored-toJSON.js')
341
+
342
+ await fs.promises.writeFile(destination, code)
343
+ const stringify = require(destination)
344
+
345
+ t.assert.equal(
346
+ stringify({ value: new Date('2020-01-02T03:04:05.000Z') }),
347
+ '{"value":"2020-01-02T03:04:05.000Z"}'
348
+ )
349
+ t.assert.equal(stringify({ value: 42 }), '{"value":42}')
350
+ t.assert.throws(() => stringify({ value: true }), /does not match schema definition/)
351
+ })
352
+
321
353
  test('does not emit unused inline validators', (t) => {
322
354
  const code = build({ mode: 'standalone', inlineValidators: true })
323
355
 
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const build = require('..')
5
+
6
+ function schemaWithTypo () {
7
+ return { anyOf: [{ type: 'number', mininum: 10 }] }
8
+ }
9
+
10
+ for (const compileValidators of [false, true]) {
11
+ const mode = compileValidators ? 'eager' : 'lazy'
12
+
13
+ for (const [name, ajv] of [
14
+ ['omitted', undefined],
15
+ ['empty', {}],
16
+ ['undefined', { strictSchema: undefined }],
17
+ ['null', { strictSchema: null }],
18
+ ['false', { strictSchema: false }],
19
+ ['strict true', { strict: true }],
20
+ ['explicit false with strict true', { strict: true, strictSchema: false }]
21
+ ]) {
22
+ test(`strictSchema keeps permissive behavior for ${name} (${mode})`, t => {
23
+ const stringify = build(schemaWithTypo(), { ajv, compileValidators })
24
+ t.assert.equal(stringify(1), '1')
25
+ })
26
+ }
27
+
28
+ test(`strictSchema true rejects unknown keywords (${mode})`, t => {
29
+ const options = { ajv: { strictSchema: true }, compileValidators }
30
+ const unknownKeyword = /unknown keyword: "mininum"/
31
+
32
+ if (compileValidators) {
33
+ t.assert.throws(() => build(schemaWithTypo(), options), unknownKeyword)
34
+ } else {
35
+ const stringify = build(schemaWithTypo(), options)
36
+ t.assert.throws(() => stringify(1), unknownKeyword)
37
+ }
38
+ })
39
+
40
+ test(`strictSchema log warns about unknown keywords (${mode})`, t => {
41
+ const warnings = []
42
+ const logger = {
43
+ log () {},
44
+ error () {},
45
+ warn (message) { warnings.push(message) }
46
+ }
47
+ const stringify = build(schemaWithTypo(), {
48
+ ajv: { strictSchema: 'log', logger },
49
+ compileValidators
50
+ })
51
+
52
+ t.assert.equal(stringify(1), '1')
53
+ t.assert.ok(warnings.some(message => message.includes('unknown keyword: "mininum"')))
54
+ })
55
+ }