├── .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 |
--------------------------------------------------------------------------------