├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .npmrc ├── .release-it.beta.json ├── .release-it.json ├── CHANGELOG.md ├── DEV_ONLY └── index.tsx ├── LICENSE ├── README.md ├── __tests__ └── index.ts ├── benchmark └── index.js ├── es-to-mjs.js ├── index.d.ts ├── jest.config.js ├── package.json ├── rollup.config.js ├── src └── index.ts ├── webpack └── webpack.config.dev.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "benchmark": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "loose": true 9 | } 10 | ], 11 | "minify" 12 | ] 13 | }, 14 | "development": { 15 | "plugins": [ 16 | [ 17 | "@babel/plugin-transform-runtime", 18 | { 19 | "corejs": false, 20 | "helpers": false, 21 | "regenerator": true, 22 | "useESModules": true 23 | } 24 | ], 25 | "@babel/plugin-proposal-class-properties", 26 | "@babel/plugin-proposal-json-strings" 27 | ] 28 | }, 29 | "lib": { 30 | "presets": [ 31 | [ 32 | "@babel/preset-env", 33 | { 34 | "loose": true 35 | } 36 | ] 37 | ] 38 | }, 39 | "test": { 40 | "presets": [ 41 | [ 42 | "@babel/preset-env", 43 | { 44 | "loose": true 45 | } 46 | ] 47 | ] 48 | } 49 | }, 50 | "plugins": ["@babel/plugin-proposal-json-strings"], 51 | "presets": [ 52 | "@babel/preset-typescript", 53 | [ 54 | "@babel/preset-env", 55 | { 56 | "loose": true, 57 | "modules": false 58 | } 59 | ] 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base"], 3 | "globals": { 4 | "__dirname": true, 5 | "Buffer": true, 6 | "global": true, 7 | "module": true, 8 | "process": true, 9 | "require": true, 10 | "TypedArray": true 11 | }, 12 | "parser": "babel-eslint", 13 | "rules": { 14 | "no-bitwise": 0, 15 | "no-plusplus": 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .nyc_output 3 | coverage 4 | dist 5 | node_modules 6 | mjs 7 | *.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc 3 | .gitignore 4 | .idea 5 | .nyc_output 6 | .yarnrc 7 | __tests__ 8 | benchmark 9 | coverage 10 | DEV_ONLY 11 | node_modules 12 | src 13 | webpack 14 | es-to-mjs.js 15 | jest.config.js 16 | rollup.config.js 17 | yarn.lock 18 | *.log 19 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true -------------------------------------------------------------------------------- /.release-it.beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "release": true, 4 | "tagName": "v${version}" 5 | }, 6 | "npm": { 7 | "tag": "next" 8 | }, 9 | "preReleaseId": "beta" 10 | } 11 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "release": true, 4 | "tagName": "v${version}" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # fast-stringify CHANGELOG 2 | 3 | ## 2.0.0 4 | 5 | - Rewritten in TypeScript 6 | - Better reference key identification 7 | 8 | ### BREAKING CHANGES 9 | 10 | - CommonJS builds no longer need `.default` (`const stringify = require('fast-stringify');`) 11 | - Reference keys on circular objects now reflect the key structure leading to the object 12 | 13 | ## 1.1.2 14 | 15 | - Update documentation to explain the purpose of the library and its relationship to `JSON.stringify` 16 | - Add `typeof value === 'object'` check to only cache objects for faster iteration 17 | - Improve internal `indexOf` lookup for faster cache comparisons 18 | 19 | ## 1.1.1 20 | 21 | - Upgrade to use Babel 7 for transformations 22 | 23 | ## 1.1.0 24 | 25 | - Add ESM support for NodeJS with separate [`.mjs` extension](https://nodejs.org/api/esm.html) exports 26 | 27 | ## 1.0.4 28 | 29 | - Reduce runtime function checks 30 | 31 | ## 1.0.3 32 | 33 | - Abandon use of `WeakSet` for caching, instead using more consistent and flexible `Array` cache with custom modifier methods 34 | 35 | ## 1.0.2 36 | 37 | - Fix issue where directly nested objects like `window` were throwing circular errors when nested in a parent object 38 | 39 | ## 1.0.1 40 | 41 | - Fix repeated reference issue (#2) 42 | 43 | ## 1.0.0 44 | 45 | - Initial release 46 | -------------------------------------------------------------------------------- /DEV_ONLY/index.tsx: -------------------------------------------------------------------------------- 1 | // external dependencies 2 | import React from 'react'; 3 | 4 | // src 5 | import stringify from '../src'; 6 | import safeStringify from 'json-stringify-safe'; 7 | 8 | document.body.style.backgroundColor = '#1d1d1d'; 9 | document.body.style.color = '#d5d5d5'; 10 | document.body.style.margin = '0px'; 11 | document.body.style.padding = '0px'; 12 | 13 | const div = document.createElement('div'); 14 | 15 | div.textContent = 'Check the console for details.'; 16 | 17 | document.body.appendChild(div); 18 | 19 | function Circular(value) { 20 | this.deeply = { 21 | nested: { 22 | reference: this, 23 | value, 24 | }, 25 | }; 26 | } 27 | 28 | const StatelessComponent = () =>
test
; 29 | 30 | type Props = {}; 31 | type State = { 32 | foo: string; 33 | }; 34 | 35 | class StatefulComponent extends React.Component { 36 | state: State = { 37 | foo: 'bar', 38 | }; 39 | 40 | render() { 41 | return ; 42 | } 43 | } 44 | 45 | const a = { 46 | foo: 'bar', 47 | }; 48 | 49 | const b = { 50 | a, 51 | }; 52 | 53 | const object = { 54 | arrayBuffer: new Uint16Array([1, 2, 3]).buffer, 55 | string: 'foo', 56 | date: new Date(2016, 8, 1), 57 | num: 12, 58 | bool: true, 59 | func() { 60 | alert('y'); 61 | }, 62 | *generator() { 63 | let value = yield 1; 64 | 65 | yield value + 2; 66 | }, 67 | undef: undefined, 68 | nil: null, 69 | obj: { 70 | foo: 'bar', 71 | }, 72 | arr: ['foo', 'bar'], 73 | el: document.createElement('div'), 74 | math: Math, 75 | regexp: /test/, 76 | circular: new Circular('foo'), 77 | infinity: Infinity, 78 | 79 | // comment out for older browser testing 80 | symbol: Symbol('test'), 81 | dataView: new DataView(new ArrayBuffer(2)), 82 | err: new Error('Stuff'), 83 | float32Array: new Float32Array([1, 2, 3]), 84 | float64Array: new Float64Array([1, 2, 3]), 85 | int16Array: new Int16Array([1, 2, 3]), 86 | int32Array: new Int32Array([1, 2, 3]), 87 | int8Array: new Int8Array([1, 2, 3]), 88 | map: new Map().set(true, 7).set({ foo: 3 }, ['abc']), 89 | promise: Promise.resolve(1), 90 | set: new Set().add('foo').add(2), 91 | uint16Array: new Uint16Array([1, 2, 3]), 92 | uint32Array: new Uint32Array([1, 2, 3]), 93 | uint8Array: new Uint8Array([1, 2, 3]), 94 | uint8ClampedArray: new Uint8ClampedArray([1, 2, 3]), 95 | weakMap: new WeakMap().set({}, 7).set({ foo: 3 }, ['abc']), 96 | weakSet: new WeakSet().add({}).add({ foo: 'bar' }), 97 | doc: document, 98 | win: window, 99 | 100 | ReactStatefulClass: StatefulComponent, 101 | // @ts-ignore 102 | ReactStatefulElement: , 103 | ReactStatelessClass: StatelessComponent, 104 | ReactStatelessElement: , 105 | }; 106 | 107 | console.group('circular'); 108 | console.log(stringify(new Circular('foo'))); 109 | console.log(safeStringify(new Circular('foo'))); 110 | console.groupEnd(); 111 | 112 | console.group('window'); 113 | console.log(stringify(window)); 114 | console.log(safeStringify(window)); 115 | console.groupEnd(); 116 | 117 | console.group('object of many types'); 118 | console.log(stringify(object, null, 2)); 119 | console.log(safeStringify(object, null, 2)); 120 | console.groupEnd(); 121 | 122 | console.group('custom replacer'); 123 | console.log(stringify(object.arrayBuffer, (key, value) => Buffer.from(value).toString('utf8'))); 124 | console.groupEnd(); 125 | 126 | console.group('custom circular replacer'); 127 | console.log( 128 | stringify(new Circular('foo'), null, null, (key, value, refCount) => `Ref-${refCount}`), 129 | ); 130 | console.groupEnd(); 131 | 132 | class Foo { 133 | value: string; 134 | 135 | constructor(value: string) { 136 | this.value = value; 137 | 138 | return this; 139 | } 140 | } 141 | 142 | const shallowObject = { 143 | boolean: true, 144 | fn() { 145 | return 'foo'; 146 | }, 147 | nan: NaN, 148 | nil: null, 149 | number: 123, 150 | string: 'foo', 151 | undef: undefined, 152 | [Symbol('key')]: 'value', 153 | }; 154 | 155 | const deepObject = Object.assign({}, shallowObject, { 156 | array: ['foo', { bar: 'baz' }], 157 | buffer: new Buffer('this is a test buffer'), 158 | error: new Error('boom'), 159 | foo: new Foo('value'), 160 | map: new Map().set('foo', { bar: 'baz' }), 161 | object: { foo: { bar: 'baz' } }, 162 | promise: Promise.resolve('foo'), 163 | regexp: /foo/, 164 | set: new Set().add('foo').add({ bar: 'baz' }), 165 | weakmap: new WeakMap([[{}, 'foo'], [{}, 'bar']]), 166 | weakset: new WeakSet([{}, {}]), 167 | }); 168 | 169 | const circularObject = Object.assign({}, deepObject, { 170 | deeply: { 171 | nested: { 172 | reference: {}, 173 | }, 174 | }, 175 | }); 176 | 177 | console.group('other object of many types'); 178 | console.log(stringify(object, null, 2)); 179 | console.log(safeStringify(object, null, 2)); 180 | console.groupEnd(); 181 | 182 | const shared = { bar: [] }; 183 | 184 | const similar = { 185 | foo: shared, 186 | bar: shared, 187 | baz: { 188 | baz: null, 189 | foo: null, 190 | }, 191 | }; 192 | 193 | similar.baz.foo = similar.foo; 194 | similar.baz.baz = similar.baz; 195 | 196 | console.group('object of shared types'); 197 | console.log(stringify(similar, null, 2)); 198 | console.log(safeStringify(similar, null, 2)); 199 | console.groupEnd(); 200 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tony Quetano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fast-stringify 2 | 3 | A tiny, [blazing fast](#benchmarks) stringifier that safely handles circular objects 4 | 5 | ## Table of contents 6 | 7 | - [fast-stringify](#fast-stringify) 8 | - [Table of contents](#Table-of-contents) 9 | - [Summary](#Summary) 10 | - [Usage](#Usage) 11 | - [stringify](#stringify) 12 | - [Importing](#Importing) 13 | - [Benchmarks](#Benchmarks) 14 | - [Simple objects](#Simple-objects) 15 | - [Complex objects](#Complex-objects) 16 | - [Circular objects](#Circular-objects) 17 | - [Special objects](#Special-objects) 18 | - [Development](#Development) 19 | 20 | ## Summary 21 | 22 | The fastest way to stringify an object will always be the native `JSON.stringify`, but it does not support circular objects out of the box. If you need to stringify objects that have circular references, `fast-stringify` is there for you! It maintains a very similar API to the native `JSON.stringify`, and aims to be the most performant stringifier that handles circular references. 23 | 24 | ## Usage 25 | 26 | ```javascript 27 | import stringify from 'fast-stringify'; 28 | 29 | const object = { 30 | foo: 'bar', 31 | deeply: { 32 | recursive: { 33 | object: {}, 34 | }, 35 | }, 36 | }; 37 | 38 | object.deeply.recursive.object = object.deeply.recursive; 39 | 40 | console.log(stringify(object)); 41 | // {"foo":"bar","deeply":{"recursive":{"object":"[ref=.deeply.recursive]"}}} 42 | ``` 43 | 44 | #### stringify 45 | 46 | ```ts 47 | type StandardReplacer = (key: string, value: any) => any; 48 | type CircularReplacer = (key: string, value: any, referenceKey: string) => any; 49 | 50 | function stringify( 51 | value: any, 52 | replacer?: StandardReplacer, 53 | indent?: number, 54 | circularReplacer: CircularReplacer, 55 | ): string; 56 | ``` 57 | 58 | Stringifies the object passed based on the parameters you pass. The only required value is the `object`. The additional parameters passed will customize how the string is compiled. 59 | 60 | - `value` => the value to stringify 61 | - `replacer` => function to customize how the non-circular value is stringified (see [the documentation for JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) for more details) 62 | - `indent` => number of spaces to indent the stringified object for pretty-printing (see [the documentation for JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) for more details) 63 | - `circularReplacer` => function to customize how the circular value is stringified (defaults to `[ref=##]` where `##` is the `referenceKey`) 64 | - `referenceKey` is a dot-separated key list reflecting the nested key the object was originally declared at 65 | 66 | ## Importing 67 | 68 | ```javascript 69 | // ESM in browsers 70 | import stringify from 'fast-stringify'; 71 | 72 | // ESM in NodeJS 73 | import stringify from 'fast-stringify/mjs'; 74 | 75 | // CommonJS 76 | const stringify = require('fast-stringify'); 77 | ``` 78 | 79 | ## Benchmarks 80 | 81 | #### Simple objects 82 | 83 | _Small number of properties, all values are primitives_ 84 | 85 | | | Operations / second | Relative margin of error | 86 | | -------------------------- | ------------------- | ------------------------ | 87 | | **fast-stringify** | **598,072** | **0.59%** | 88 | | fast-json-stable-stringify | 339,082 | 0.86% | 89 | | json-stringify-safe | 333,447 | 0.46% | 90 | | json-stable-stringify | 255,619 | 0.71% | 91 | | json-cycle | 194,553 | 0.60% | 92 | | decircularize | 141,821 | 1.35% | 93 | 94 | #### Complex objects 95 | 96 | _Large number of properties, values are a combination of primitives and complex objects_ 97 | 98 | | | Operations / second | Relative margin of error | 99 | | -------------------------- | ------------------- | ------------------------ | 100 | | **fast-stringify** | **97,559** | **0.32%** | 101 | | json-stringify-safe | 59,948 | 0.44% | 102 | | fast-json-stable-stringify | 57,656 | 1.14% | 103 | | json-cycle | 51,892 | 0.59% | 104 | | json-stable-stringify | 39,180 | 1.01% | 105 | | decircularize | 27,047 | 0.84% | 106 | 107 | #### Circular objects 108 | 109 | _Objects that deeply reference themselves_ 110 | 111 | | | Operations / second | Relative margin of error | 112 | | ------------------------------------------ | ------------------- | ------------------------ | 113 | | **fast-stringify** | **87,030** | **0.51%** | 114 | | json-stringify-safe | 56,329 | 0.49% | 115 | | json-cycle | 48,116 | 0.77% | 116 | | decircularize | 25,240 | 0.68% | 117 | | fast-json-stable-stringify (not supported) | 0 | 0.00% | 118 | | json-stable-stringify (not supported) | 0 | 0.00% | 119 | 120 | #### Special objects 121 | 122 | _Custom constructors, React components, etc_ 123 | 124 | | | Operations / second | Relative margin of error | 125 | | -------------------------- | ------------------- | ------------------------ | 126 | | **fast-stringify** | **24,250** | **0.38%** | 127 | | json-stringify-safe | 19,526 | 0.52% | 128 | | json-cycle | 18,433 | 0.74% | 129 | | fast-json-stable-stringify | 18,202 | 0.73% | 130 | | json-stable-stringify | 13,041 | 0.87% | 131 | | decircularize | 9,175 | 0.82% | 132 | 133 | ## Development 134 | 135 | Standard practice, clone the repo and `npm i` to get the dependencies. The following npm scripts are available: 136 | 137 | - `benchmark` => run benchmark tests against other equality libraries 138 | - `build` => build dist files with `rollup` 139 | - `clean` => run `clean:dist` and `clean:mjs` scripts 140 | - `clean:dist` => run `rimraf` on the `dist` folder 141 | - `clean:mjs` => run `rimraf` on the `mjs` folder 142 | - `copy:mjs` => copy and transform the ESM file generated by `dist` to be consumable as an `.mjs` file 143 | - `dev` => start webpack playground App 144 | - `dist` => run `clean`, `build`, and `copy:mjs` scripts 145 | - `lint` => run ESLint on all files in `src` folder (also runs on `dev` script) 146 | - `lint:fix` => run `lint` script, but with auto-fixer 147 | - `prepublishOnly` => run `lint`, `typecheck`, `test:coverage`, and `dist` scripts 148 | - `release` => run `release-it` for standard versions (expected to be installed globally) 149 | - `release:beta` => run `release-it` for beta versions (expected to be installed globally) 150 | - `start` => run `dev` 151 | - `test` => run Jest with NODE_ENV=test on all files in `__tests__` folder 152 | - `test:coverage` => run same script as `test` with code coverage calculation 153 | - `test:watch` => run same script as `test` but keep persistent watcher 154 | - `typecheck` => run TypeScript types validation 155 | -------------------------------------------------------------------------------- /__tests__/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import * as React from 'react'; 4 | 5 | import stringify from '../src'; 6 | import { isMainThread } from 'worker_threads'; 7 | 8 | class Foo { 9 | value: string; 10 | 11 | constructor(value: string) { 12 | this.value = value; 13 | } 14 | } 15 | 16 | const simpleObject = { 17 | boolean: true, 18 | fn() { 19 | return 'foo'; 20 | }, 21 | nan: NaN, 22 | nil: null, 23 | number: 123, 24 | string: 'foo', 25 | undef: undefined, 26 | [Symbol('key')]: 'value', 27 | }; 28 | 29 | const complexObject = Object.assign({}, simpleObject, { 30 | array: ['foo', { bar: 'baz' }], 31 | buffer: new Buffer('this is a test buffer'), 32 | error: new Error('boom'), 33 | foo: new Foo('value'), 34 | map: new Map().set('foo', { bar: 'baz' }), 35 | object: { foo: { bar: 'baz' } }, 36 | promise: Promise.resolve('foo'), 37 | regexp: /foo/, 38 | set: new Set().add('foo').add({ bar: 'baz' }), 39 | weakmap: new WeakMap([[{}, 'foo'], [{}, 'bar']]), 40 | weakset: new WeakSet([{}, {}]), 41 | }); 42 | 43 | const circularObject = Object.assign({}, complexObject, { 44 | deeply: { 45 | nested: { 46 | reference: {}, 47 | }, 48 | }, 49 | }); 50 | 51 | const specialObject = Object.assign({}, complexObject, { 52 | react: React.createElement('main', { 53 | children: [ 54 | React.createElement('h1', { children: 'Title' }), 55 | React.createElement('p', { children: 'Content' }), 56 | React.createElement('p', { children: 'Content' }), 57 | React.createElement('p', { children: 'Content' }), 58 | React.createElement('p', { children: 'Content' }), 59 | React.createElement('div', { 60 | children: [ 61 | React.createElement('div', { 62 | children: 'Item', 63 | style: { flex: '1 1 auto' }, 64 | }), 65 | React.createElement('div', { 66 | children: 'Item', 67 | style: { flex: '1 1 0' }, 68 | }), 69 | ], 70 | style: { display: 'flex' }, 71 | }), 72 | ], 73 | }), 74 | }); 75 | 76 | circularObject.deeply.nested.reference = circularObject; 77 | 78 | describe('handling of object types', () => { 79 | it('should handle simple objects', () => { 80 | const result = stringify(simpleObject); 81 | 82 | expect(result).toEqual(JSON.stringify(simpleObject)); 83 | }); 84 | 85 | it('should handle simple objects with a custom replacer', () => { 86 | const replacer = (key: string, value: any) => 87 | value && typeof value === 'object' ? value : `primitive-${value}`; 88 | 89 | const result = stringify(simpleObject, replacer); 90 | 91 | expect(result).toEqual(JSON.stringify(simpleObject, replacer)); 92 | }); 93 | 94 | it('should handle simple objects with indentation', () => { 95 | const result = stringify(simpleObject, null, 2); 96 | 97 | expect(result).toEqual(JSON.stringify(simpleObject, null, 2)); 98 | }); 99 | 100 | it('should handle complex objects', () => { 101 | const result = stringify(complexObject); 102 | 103 | expect(result).toEqual(JSON.stringify(complexObject)); 104 | }); 105 | 106 | it('should handle complex objects with a custom replacer', () => { 107 | const replacer = (key: string, value: any) => 108 | value && typeof value === 'object' ? value : `primitive-${value}`; 109 | 110 | const result = stringify(complexObject, replacer); 111 | 112 | expect(result).toEqual(JSON.stringify(complexObject, replacer)); 113 | }); 114 | 115 | it('should handle circular objects', () => { 116 | const result = stringify(circularObject); 117 | 118 | expect(result).toEqual( 119 | JSON.stringify( 120 | circularObject, 121 | (() => { 122 | const cache = []; 123 | 124 | return (key, value) => { 125 | if (value && typeof value === 'object' && ~cache.indexOf(value)) { 126 | return `[ref=.]`; 127 | } 128 | 129 | cache.push(value); 130 | 131 | return value; 132 | }; 133 | })(), 134 | ), 135 | ); 136 | }); 137 | 138 | it('should handle circular objects with a custom circular replacer', () => { 139 | const result = stringify( 140 | circularObject, 141 | null, 142 | null, 143 | (key: string, value: string, referenceKey: string) => referenceKey, 144 | ); 145 | const circularReplacer = (() => { 146 | const cache = []; 147 | 148 | return (key, value) => { 149 | if (value && typeof value === 'object' && ~cache.indexOf(value)) { 150 | return '.'; 151 | } 152 | 153 | cache.push(value); 154 | 155 | return value; 156 | }; 157 | })(); 158 | 159 | expect(result).toEqual(JSON.stringify(circularObject, circularReplacer)); 160 | }); 161 | 162 | it('should handle special objects', () => { 163 | const result = stringify(specialObject); 164 | 165 | expect(result).toEqual(JSON.stringify(specialObject)); 166 | }); 167 | 168 | it('should handle special objects with a custom circular replacer', () => { 169 | const result = stringify( 170 | specialObject, 171 | null, 172 | null, 173 | (key: string, value: string, referenceKey: string) => referenceKey, 174 | ); 175 | const circularReplacer = (() => { 176 | const cache = []; 177 | 178 | return (key: string, value: any) => { 179 | if (value && typeof value === 'object' && ~cache.indexOf(value)) { 180 | return '.'; 181 | } 182 | 183 | cache.push(value); 184 | 185 | return value; 186 | }; 187 | })(); 188 | 189 | expect(result).toEqual(JSON.stringify(specialObject, circularReplacer)); 190 | }); 191 | }); 192 | 193 | describe('key references', () => { 194 | it('should point to the top level object when it is referenced', () => { 195 | const object = { 196 | foo: 'bar', 197 | deeply: { 198 | recursive: { 199 | object: {}, 200 | }, 201 | }, 202 | }; 203 | 204 | object.deeply.recursive.object = object; 205 | 206 | expect(stringify(object)).toEqual(`{"foo":"bar","deeply":{"recursive":{"object":"[ref=.]"}}}`); 207 | }); 208 | 209 | it('should point to the nested object when it is referenced', () => { 210 | const object = { 211 | foo: 'bar', 212 | deeply: { 213 | recursive: { 214 | object: {}, 215 | }, 216 | }, 217 | }; 218 | 219 | object.deeply.recursive.object = object.deeply.recursive; 220 | 221 | expect(stringify(object)).toEqual( 222 | `{"foo":"bar","deeply":{"recursive":{"object":"[ref=.deeply.recursive]"}}}`, 223 | ); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assertDeepStrictEqual = require('assert').deepStrictEqual; 4 | const Benchmark = require('benchmark'); 5 | const React = require('react'); 6 | 7 | function Foo(value) { 8 | this.value = value; 9 | 10 | return this; 11 | } 12 | 13 | const shallowObject = { 14 | boolean: true, 15 | fn() { 16 | return 'foo'; 17 | }, 18 | nan: NaN, 19 | nil: null, 20 | number: 123, 21 | string: 'foo', 22 | undef: undefined, 23 | [Symbol('key')]: 'value' 24 | }; 25 | 26 | const deepObject = Object.assign({}, shallowObject, { 27 | array: ['foo', {bar: 'baz'}], 28 | buffer: new Buffer('this is a test buffer'), 29 | error: new Error('boom'), 30 | foo: new Foo('value'), 31 | map: new Map().set('foo', {bar: 'baz'}), 32 | object: {foo: {bar: 'baz'}}, 33 | promise: Promise.resolve('foo'), 34 | regexp: /foo/, 35 | set: new Set().add('foo').add({bar: 'baz'}), 36 | weakmap: new WeakMap([[{}, 'foo'], [{}, 'bar']]), 37 | weakset: new WeakSet([{}, {}]) 38 | }); 39 | 40 | const circularObject = Object.assign({}, deepObject, { 41 | deeply: { 42 | nested: { 43 | reference: {} 44 | } 45 | } 46 | }); 47 | 48 | const specialObject = Object.assign({}, deepObject, { 49 | react: React.createElement('main', { 50 | children: [ 51 | React.createElement('h1', {children: 'Title'}), 52 | React.createElement('p', {children: 'Content'}), 53 | React.createElement('p', {children: 'Content'}), 54 | React.createElement('p', {children: 'Content'}), 55 | React.createElement('p', {children: 'Content'}), 56 | React.createElement('div', { 57 | children: [ 58 | React.createElement('div', { 59 | children: 'Item', 60 | style: {flex: '1 1 auto'} 61 | }), 62 | React.createElement('div', { 63 | children: 'Item', 64 | style: {flex: '1 1 0'} 65 | }) 66 | ], 67 | style: {display: 'flex'} 68 | }) 69 | ] 70 | }) 71 | }); 72 | 73 | circularObject.deeply.nested.reference = circularObject; 74 | 75 | const packages = { 76 | decircularize: (value) => JSON.stringify(require('decircularize')(value)), 77 | 'fast-json-stable-stringify': require('fast-json-stable-stringify'), 78 | 'fast-stringify': require('../dist/index.cjs'), 79 | 'json-cycle': (value) => JSON.stringify(require('json-cycle').decycle(value)), 80 | 'json-stable-stringify': require('json-stable-stringify'), 81 | 'json-stringify-safe': require('json-stringify-safe') 82 | }; 83 | 84 | console.log(''); 85 | 86 | const runShallowSuite = () => { 87 | console.log('Running shallow object performance comparison...'); 88 | console.log(''); 89 | 90 | const suite = new Benchmark.Suite(); 91 | 92 | for (let name in packages) { 93 | suite.add(name, () => packages[name](shallowObject)); 94 | } 95 | 96 | return new Promise((resolve) => { 97 | suite 98 | .on('cycle', (event) => { 99 | const result = event.target.toString(); 100 | 101 | return console.log(result); 102 | }) 103 | .on('complete', function() { 104 | console.log(''); 105 | console.log(`...complete, the fastest is ${this.filter('fastest').map('name')}.`); 106 | 107 | resolve(); 108 | }) 109 | .run({async: true}); 110 | }); 111 | }; 112 | 113 | const runDeepSuite = () => { 114 | console.log('Running deep object performance comparison...'); 115 | console.log(''); 116 | 117 | const suite = new Benchmark.Suite(); 118 | 119 | for (let name in packages) { 120 | suite.add(name, () => packages[name](deepObject)); 121 | } 122 | 123 | return new Promise((resolve) => { 124 | suite 125 | .on('cycle', (event) => { 126 | const result = event.target.toString(); 127 | 128 | return console.log(result); 129 | }) 130 | .on('complete', function() { 131 | console.log(''); 132 | console.log(`...complete, the fastest is ${this.filter('fastest').map('name')}.`); 133 | 134 | resolve(); 135 | }) 136 | .run({async: true}); 137 | }); 138 | }; 139 | 140 | const runCircularSuite = () => { 141 | console.log('Running circular object performance comparison...'); 142 | console.log(''); 143 | 144 | const suite = new Benchmark.Suite(); 145 | 146 | for (let name in packages) { 147 | suite.add(name, () => packages[name](circularObject)); 148 | } 149 | 150 | return new Promise((resolve) => { 151 | suite 152 | .on('cycle', (event) => { 153 | const result = event.target.toString(); 154 | 155 | return console.log(result); 156 | }) 157 | .on('complete', function() { 158 | console.log(''); 159 | console.log(`...complete, the fastest is ${this.filter('fastest').map('name')}.`); 160 | 161 | resolve(); 162 | }) 163 | .run({async: true}); 164 | }); 165 | }; 166 | 167 | const runSpecialSuite = () => { 168 | console.log('Running special values object performance comparison...'); 169 | console.log(''); 170 | 171 | const suite = new Benchmark.Suite(); 172 | 173 | for (let name in packages) { 174 | suite.add(name, () => packages[name](specialObject)); 175 | } 176 | 177 | return new Promise((resolve) => { 178 | suite 179 | .on('cycle', (event) => { 180 | const result = event.target.toString(); 181 | 182 | return console.log(result); 183 | }) 184 | .on('complete', function() { 185 | console.log(''); 186 | console.log(`...complete, the fastest is ${this.filter('fastest').map('name')}.`); 187 | 188 | resolve(); 189 | }) 190 | .run({async: true}); 191 | }); 192 | }; 193 | 194 | runShallowSuite() 195 | .then(runDeepSuite) 196 | .then(runCircularSuite) 197 | .then(runSpecialSuite); 198 | -------------------------------------------------------------------------------- /es-to-mjs.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const pkg = require('./package.json'); 5 | 6 | const SOURCE = path.join(__dirname, pkg.module); 7 | const SOURCE_MAP = `${SOURCE}.map`; 8 | const DESTINATION = path.join(__dirname, 'mjs', 'index.mjs'); 9 | const DESTINATION_MAP = `${DESTINATION}.map`; 10 | 11 | const getFileName = filename => { 12 | const split = filename.split('/'); 13 | 14 | return split[split.length - 1]; 15 | }; 16 | 17 | try { 18 | if (!fs.existsSync(path.join(__dirname, 'mjs'))) { 19 | fs.mkdirSync(path.join(__dirname, 'mjs')); 20 | } 21 | 22 | fs.copyFileSync(SOURCE, DESTINATION); 23 | 24 | const contents = fs 25 | .readFileSync(DESTINATION, { encoding: 'utf8' }) 26 | .replace('fast-equals', 'fast-equals/dist/fast-equals.mjs') 27 | .replace('fast-stringify', 'fast-stringify/mjs') 28 | .replace('micro-memoize', 'micro-memoize/mjs') 29 | .replace(/\/\/# sourceMappingURL=(.*)/, (match, value) => { 30 | return match.replace(value, 'index.mjs.map'); 31 | }); 32 | 33 | fs.writeFileSync(DESTINATION, contents, { encoding: 'utf8' }); 34 | 35 | console.log(`Copied ${getFileName(SOURCE)} to ${getFileName(DESTINATION)}`); 36 | 37 | fs.copyFileSync(SOURCE_MAP, DESTINATION_MAP); 38 | 39 | console.log( 40 | `Copied ${getFileName(SOURCE_MAP)} to ${getFileName(DESTINATION_MAP)}`, 41 | ); 42 | } catch (error) { 43 | console.error(error); 44 | 45 | process.exit(1); 46 | } 47 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | type StandardReplacer = (key: string, value: any) => any; 2 | type CircularReplacer = (key: string, value: any, referenceKey: string) => any; 3 | 4 | export default function stringify( 5 | value: any, 6 | replacer?: StandardReplacer, 7 | indent?: number, 8 | circularReplacer?: CircularReplacer, 9 | ): string; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 3 | roots: [""], 4 | testRegex: "/__tests__/.*\\.(ts|tsx)$", 5 | transform: { 6 | "\\.(ts|tsx)$": "ts-jest" 7 | }, 8 | verbose: true 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "tony_quetano@planttheidea.com", 3 | "browser": "dist/index.js", 4 | "bugs": { 5 | "url": "https://github.com/planttheidea/fast-stringify/issues" 6 | }, 7 | "description": "A blazing fast stringifier that safely handles circular objects", 8 | "devDependencies": { 9 | "@babel/cli": "^7.5.0", 10 | "@babel/core": "^7.5.0", 11 | "@babel/plugin-proposal-class-properties": "^7.5.0", 12 | "@babel/plugin-proposal-json-strings": "^7.2.0", 13 | "@babel/plugin-transform-runtime": "^7.5.0", 14 | "@babel/preset-env": "^7.5.0", 15 | "@babel/preset-react": "^7.0.0", 16 | "@babel/preset-typescript": "^7.3.3", 17 | "@babel/runtime": "^7.5.0", 18 | "@types/jest": "^24.0.15", 19 | "@types/react": "^16.8.23", 20 | "babel-eslint": "^10.0.2", 21 | "babel-loader": "^8.0.6", 22 | "babel-preset-minify": "^0.5.0", 23 | "benchmark": "^2.1.4", 24 | "decircularize": "^1.0.0", 25 | "eslint": "^6.0.1", 26 | "eslint-config-airbnb-base": "^13.2.0", 27 | "eslint-friendly-formatter": "^4.0.1", 28 | "eslint-loader": "^2.2.1", 29 | "eslint-plugin-import": "^2.18.0", 30 | "fast-json-stable-stringify": "^2.0.0", 31 | "html-webpack-plugin": "^3.2.0", 32 | "jest": "^24.8.0", 33 | "json-cycle": "^1.3.0", 34 | "json-stable-stringify": "^1.0.1", 35 | "json-stringify-safe": "^5.0.1", 36 | "react": "^16.8.6", 37 | "react-dom": "^16.8.6", 38 | "rollup": "^1.16.4", 39 | "rollup-plugin-babel": "^4.3.3", 40 | "rollup-plugin-terser": "^5.1.0", 41 | "sinon": "^7.3.2", 42 | "ts-jest": "^24.0.2", 43 | "typescript": "^3.5.2", 44 | "webpack": "^4.35.2", 45 | "webpack-cli": "^3.3.5", 46 | "webpack-dev-server": "^3.7.2" 47 | }, 48 | "homepage": "https://github.com/planttheidea/fast-stringify#readme", 49 | "keywords": [ 50 | "stringify", 51 | "fast", 52 | "serialize", 53 | "json" 54 | ], 55 | "license": "MIT", 56 | "main": "dist/index.cjs.js", 57 | "module": "dist/index.esm.js", 58 | "name": "fast-stringify", 59 | "repository": { 60 | "type": "git", 61 | "url": "git+https://github.com/planttheidea/fast-stringify.git" 62 | }, 63 | "scripts": { 64 | "benchmark": "npm run build && node benchmark/index.js", 65 | "build": "NODE_ENV=production rollup -c", 66 | "clean": "npm run clean:dist && npm run clean:mjs", 67 | "clean:dist": "rimraf dist", 68 | "clean:mjs": "rimraf mjs", 69 | "copy:mjs": "node ./es-to-mjs.js", 70 | "dev": "NODE_ENV=development webpack-dev-server --colors --progress --config=webpack/webpack.config.dev.js", 71 | "dist": "npm run clean && npm run build && npm run copy:mjs", 72 | "lint": "NODE_ENV=test eslint src/*.ts --max-warnings 0", 73 | "lint:fix": "npm run lint -- --fix", 74 | "prepublishOnly": "npm run lint && npm run typecheck && npm run test:coverage && npm run dist", 75 | "release": "release-it", 76 | "release:beta": "release-it --config=.release-it.beta.json", 77 | "start": "npm run dev", 78 | "test": "NODE_PATH=. BABEL_ENV=test jest", 79 | "test:coverage": "npm test -- --coverage", 80 | "test:watch": "npm test -- --watch", 81 | "typecheck": "tsc src/* --noEmit" 82 | }, 83 | "types": "index.d.ts", 84 | "version": "2.0.0" 85 | } 86 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import { terser } from "rollup-plugin-terser"; 3 | 4 | import pkg from "./package.json"; 5 | 6 | const EXTERNALS = [ 7 | ...Object.keys(pkg.dependencies || {}), 8 | ...Object.keys(pkg.peerDependencies || {}) 9 | ]; 10 | 11 | const UMD_CONFIG = { 12 | external: EXTERNALS, 13 | input: "src/index.ts", 14 | output: { 15 | exports: 'default', 16 | file: pkg.browser, 17 | format: "umd", 18 | globals: EXTERNALS.reduce((globals, name) => { 19 | globals[name] = name; 20 | 21 | return globals; 22 | }, {}), 23 | name: pkg.name, 24 | sourcemap: true 25 | }, 26 | plugins: [ 27 | babel({ 28 | exclude: "node_modules/**", 29 | extensions: [".ts"] 30 | }) 31 | ] 32 | }; 33 | 34 | const FORMATTED_CONFIG = { 35 | ...UMD_CONFIG, 36 | output: [ 37 | { 38 | ...UMD_CONFIG.output, 39 | file: pkg.main, 40 | format: "cjs" 41 | }, 42 | { 43 | ...UMD_CONFIG.output, 44 | file: pkg.module, 45 | format: "es" 46 | } 47 | ] 48 | }; 49 | 50 | const MINIFIED_CONFIG = { 51 | ...UMD_CONFIG, 52 | output: { 53 | ...UMD_CONFIG.output, 54 | file: pkg.browser.replace(".js", ".min.js"), 55 | sourcemap: false 56 | }, 57 | plugins: [...UMD_CONFIG.plugins, terser()] 58 | }; 59 | 60 | export default [UMD_CONFIG, FORMATTED_CONFIG, MINIFIED_CONFIG]; 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @function getReferenceKey 3 | * 4 | * @description 5 | * get the reference key for the circular value 6 | * 7 | * @param keys the keys to build the reference key from 8 | * @param cutoff the maximum number of keys to include 9 | * @returns the reference key 10 | */ 11 | function getReferenceKey(keys: string[], cutoff: number) { 12 | return keys.slice(0, cutoff).join('.') || '.'; 13 | } 14 | 15 | /** 16 | * @function getCutoff 17 | * 18 | * @description 19 | * faster `Array.prototype.indexOf` implementation build for slicing / splicing 20 | * 21 | * @param array the array to match the value in 22 | * @param value the value to match 23 | * @returns the matching index, or -1 24 | */ 25 | function getCutoff(array: any[], value: any) { 26 | const { length } = array; 27 | 28 | for (let index = 0; index < length; ++index) { 29 | if (array[index] === value) { 30 | return index + 1; 31 | } 32 | } 33 | 34 | return 0; 35 | } 36 | 37 | type StandardReplacer = (key: string, value: any) => any; 38 | type CircularReplacer = (key: string, value: any, referenceKey: string) => any; 39 | 40 | /** 41 | * @function createReplacer 42 | * 43 | * @description 44 | * create a replacer method that handles circular values 45 | * 46 | * @param [replacer] a custom replacer to use for non-circular values 47 | * @param [circularReplacer] a custom replacer to use for circular methods 48 | * @returns the value to stringify 49 | */ 50 | function createReplacer( 51 | replacer?: StandardReplacer, 52 | circularReplacer?: CircularReplacer, 53 | ): StandardReplacer { 54 | const hasReplacer = typeof replacer === 'function'; 55 | const hasCircularReplacer = typeof circularReplacer === 'function'; 56 | 57 | const cache = []; 58 | const keys = []; 59 | 60 | return function replace(key: string, value: any) { 61 | if (typeof value === 'object') { 62 | if (cache.length) { 63 | const thisCutoff = getCutoff(cache, this); 64 | 65 | if (thisCutoff === 0) { 66 | cache[cache.length] = this; 67 | } else { 68 | cache.splice(thisCutoff); 69 | keys.splice(thisCutoff); 70 | } 71 | 72 | keys[keys.length] = key; 73 | 74 | const valueCutoff = getCutoff(cache, value); 75 | 76 | if (valueCutoff !== 0) { 77 | return hasCircularReplacer 78 | ? circularReplacer.call(this, key, value, getReferenceKey(keys, valueCutoff)) 79 | : `[ref=${getReferenceKey(keys, valueCutoff)}]`; 80 | } 81 | } else { 82 | cache[0] = value; 83 | keys[0] = key; 84 | } 85 | } 86 | 87 | return hasReplacer ? replacer.call(this, key, value) : value; 88 | }; 89 | } 90 | 91 | /** 92 | * @function stringify 93 | * 94 | * @description 95 | * strinigifer that handles circular values 96 | * 97 | * @param the value to stringify 98 | * @param [replacer] a custom replacer function for handling standard values 99 | * @param [indent] the number of spaces to indent the output by 100 | * @param [circularReplacer] a custom replacer function for handling circular values 101 | * @returns the stringified output 102 | */ 103 | export default function stringify( 104 | value: any, 105 | replacer?: StandardReplacer, 106 | indent?: number, 107 | circularReplacer?: CircularReplacer, 108 | ) { 109 | return JSON.stringify(value, createReplacer(replacer, circularReplacer), indent); 110 | } 111 | -------------------------------------------------------------------------------- /webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const webpack = require('webpack'); 6 | 7 | const ROOT = path.resolve(__dirname, '..'); 8 | const PORT = 3000; 9 | 10 | module.exports = { 11 | devServer: { 12 | contentBase: path.join(ROOT, 'dist'), 13 | host: 'localhost', 14 | inline: true, 15 | lazy: false, 16 | noInfo: false, 17 | port: PORT, 18 | quiet: false, 19 | stats: { 20 | colors: true, 21 | progress: true, 22 | }, 23 | }, 24 | 25 | devtool: '#source-map', 26 | 27 | entry: [path.resolve(ROOT, 'DEV_ONLY', 'index.tsx')], 28 | 29 | mode: 'development', 30 | 31 | module: { 32 | rules: [ 33 | { 34 | enforce: 'pre', 35 | include: [path.resolve(ROOT, 'src')], 36 | loader: 'eslint-loader', 37 | options: { 38 | configFile: '.eslintrc', 39 | failOnError: true, 40 | failOnWarning: false, 41 | fix: true, 42 | formatter: require('eslint-friendly-formatter'), 43 | }, 44 | test: /\.(js|ts|tsx)$/, 45 | }, 46 | { 47 | include: [path.resolve(ROOT, 'DEV_ONLY'), path.resolve(ROOT, 'src')], 48 | loader: 'babel-loader', 49 | options: { 50 | plugins: [ 51 | [ 52 | '@babel/plugin-transform-runtime', 53 | { 54 | corejs: false, 55 | helpers: false, 56 | regenerator: true, 57 | useESModules: true 58 | } 59 | ], 60 | '@babel/plugin-proposal-class-properties' 61 | ], 62 | presets: ['@babel/preset-react'], 63 | }, 64 | test: /\.(js|ts|tsx)$/, 65 | }, 66 | ], 67 | }, 68 | 69 | output: { 70 | filename: 'fast-stringify.js', 71 | library: 'fastStringify', 72 | libraryTarget: 'umd', 73 | path: path.resolve(ROOT, 'dist'), 74 | publicPath: `http://localhost:${PORT}/`, 75 | umdNamedDefine: true, 76 | }, 77 | 78 | plugins: [new webpack.EnvironmentPlugin(['NODE_ENV']), new HtmlWebpackPlugin()], 79 | 80 | resolve: { 81 | extensions: [".ts", ".tsx", ".js"] 82 | }, 83 | }; 84 | --------------------------------------------------------------------------------