├── .dockerignore ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── copy_to_lib └── esm │ └── package.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── JsonStreamStringify.ts ├── test-src ├── .eslintrc.js ├── JsonStreamStringify.esm.ts ├── JsonStreamStringify.spec.ts └── JsonStreamStringify.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | dist 4 | lib 5 | .github 6 | test 7 | coverage 8 | .nyc_output 9 | .rpt2_cache 10 | *.gitignore 11 | *.gitignore* 12 | tmp 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "plugins": ["@typescript-eslint"], 4 | "parser": "@typescript-eslint/parser", 5 | "settings": { 6 | "react": { 7 | "version": "0" 8 | } 9 | }, 10 | "rules": { 11 | "max-line-length": 0, 12 | "max-len": 0, 13 | "import-name": 0, 14 | "no-underscore-dangle": 0, 15 | "no-param-reassign": 0, 16 | "no-shadow": "off", 17 | "@typescript-eslint/no-shadow": ["error"], 18 | "no-unused-vars": "off", 19 | "@typescript-eslint/no-unused-vars": "error", 20 | "import/prefer-default-export": 0, 21 | "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }] 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: JsonStreamStringify-CI 2 | on: [push, pull_request] 3 | concurrency: 4 | group: ${{ github.head_ref || 'push' }} 5 | cancel-in-progress: true 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | # if: startsWith(github.ref, 'refs/tags/') || github.head_ref || ${{ env.ACT }} 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: volta-cli/action@v4 13 | with: 14 | node-version: 18 15 | - run: npm ci 16 | - run: npm run lint 17 | - run: npm run build 18 | - run: npx es-check es8 'lib/umd/index.js' 19 | - run: cp -r copy_to_lib/* lib 20 | # install older mocha for node<14 tests 21 | - run: npm i mocha@6 --no-save 22 | - uses: montudor/action-zip@v1 23 | with: 24 | args: zip -0 -qq -r node_modules.zip node_modules 25 | - uses: actions/upload-artifact@v3 26 | with: 27 | name: node-modules-artifact-${{ github.run_number }} 28 | retention-days: 1 29 | path: node_modules.zip 30 | - uses: actions/upload-artifact@v3 31 | with: 32 | name: build-artifact-${{ github.run_number }} 33 | retention-days: 1 34 | path: | 35 | package.json 36 | package-lock.json 37 | README.md 38 | LICENSE 39 | lib 40 | test 41 | 42 | coverage: 43 | needs: build 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/download-artifact@v3 47 | with: 48 | name: build-artifact-${{ github.run_number }} 49 | - uses: actions/download-artifact@v3 50 | with: 51 | name: node-modules-artifact-${{ github.run_number }} 52 | - uses: montudor/action-zip@v1 53 | with: 54 | args: unzip -qq node_modules.zip -d . 55 | - uses: volta-cli/action@v4 56 | with: 57 | node-version: 18 58 | - run: npm run coverage 59 | - uses: actions/upload-artifact@v3 60 | with: 61 | name: coverage-${{ github.run_number }} 62 | retention-days: 1 63 | path: coverage 64 | - name: Coveralls 65 | uses: coverallsapp/github-action@master 66 | if: ${{ !env.ACT }} 67 | with: 68 | github-token: ${{ secrets.GITHUB_TOKEN }} 69 | 70 | test: 71 | needs: build 72 | strategy: 73 | matrix: 74 | node: ['7.10.1', 10, 16] # 18 is tested by coverage in build stage 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/download-artifact@v3 78 | with: 79 | name: build-artifact-${{ github.run_number }} 80 | - uses: actions/download-artifact@v3 81 | with: 82 | name: node-modules-artifact-${{ github.run_number }} 83 | - uses: montudor/action-zip@v1 84 | with: 85 | args: unzip -qq node_modules.zip -d . 86 | - uses: volta-cli/action@v4 87 | with: 88 | node-version: ${{ matrix.node }} 89 | - run: volta run --node ${{ matrix.node }} npm test 90 | 91 | deploy: 92 | needs: [test, coverage] 93 | runs-on: ubuntu-latest 94 | environment: deploy 95 | if: startsWith(github.ref, 'refs/tags/') 96 | steps: 97 | # Setup .npmrc file to publish to npm 98 | - uses: actions/setup-node@v3 99 | with: 100 | node-version: 18 101 | registry-url: 'https://registry.npmjs.org' 102 | - uses: actions/download-artifact@v3 103 | with: 104 | name: build-artifact-${{ github.run_number }} 105 | - run: npm publish --access public 106 | if: ${{ !env.ACT }} 107 | env: 108 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # typescript cache? 21 | .rpt2_cache 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules 34 | jspm_packages 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional REPL history 40 | .node_repl_history 41 | 42 | # Build artifacts 43 | dist 44 | lib 45 | tmp 46 | 47 | # Test folder, because tests are compiled 48 | test/* 49 | 50 | *.gitignore* -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug Mocha", 11 | "program": "${workspaceFolder}\\node_modules\\mocha\\bin\\_mocha", 12 | "stopOnEntry": false, 13 | "args": ["-R", "spec", "-b"], 14 | "cwd": "${workspaceFolder}", 15 | "runtimeExecutable": null 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "typescript.tsdk": "node_modules\\typescript\\lib" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 Faleij 4 | 5 | Permission is hereby granted, free of charge, 6 | to any person obtaining a copy of this software and 7 | associated documentation files (the "Software"), to 8 | deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, 10 | merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom 12 | the Software is furnished to do so, 13 | subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 21 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 22 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 23 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 24 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Stream Stringify 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM Downloads][downloads-image]][downloads-url] 5 | [![Build Status][travis-image]][travis-url] 6 | [![Coverage Status][coveralls-image]][coveralls-url] 7 | [![License][license-image]](LICENSE) 8 | [![Donate][donate-image]][donate-url] 9 | 10 | JSON Stringify as a Readable Stream with rescursive resolving of any readable streams and Promises. 11 | 12 | ## Important and Breaking Changes in v3.1.0 13 | 14 | - Completely rewritten from scratch - again 15 | - Buffer argument added (Stream will not output data untill buffer size is reached - improves speed) 16 | - Dropped support for node <7.10.1 - async supporting environment now required 17 | 18 | ## Main Features 19 | 20 | - Promises are rescursively resolved and the result is piped through JsonStreamStringify 21 | - Streams (Object mode) are recursively read and output as arrays 22 | - Streams (Non-Object mode) are output as a single string 23 | - Output is streamed optimally with as small chunks as possible 24 | - Cycling of cyclical structures and dags using [Douglas Crockfords cycle algorithm](https://github.com/douglascrockford/JSON-js)* 25 | - Great memory management with reference release after processing and WeakMap/Set reference handling 26 | - Optimal stream pressure handling 27 | - Tested and runs on ES5**, ES2015**, ES2016 and later 28 | - Bundled as UMD and Module 29 | 30 | \* Off by default since v2 31 | \** With [polyfills](#usage) 32 | 33 | ## Install 34 | 35 | ```bash 36 | npm install --save json-stream-stringify 37 | 38 | # Optional if you need polyfills 39 | # Make sure to include these if you target NodeJS <=v6 or browsers 40 | npm install --save @babel/polyfill @babel/runtime 41 | ``` 42 | 43 | ## Usage 44 | 45 | Using Node v8 or later with ESM / Webpack / Browserify / Rollup 46 | 47 | ### No Polyfills, TS / ESM 48 | 49 | ```javascript 50 | import { JsonStreamStringify } from 'json-stream-stringify'; 51 | ``` 52 | 53 | ### Polyfilled, TS / ESM 54 | 55 | install @babel/runtime-corejs3 and corejs@3 56 | 57 | ```javascript 58 | import { JsonStreamStringify } from 'json-stream-stringify/polyfill'; 59 | import { JsonStreamStringify } from 'json-stream-stringify/module.polyfill'; // force ESM 60 | ``` 61 | 62 | ### Using Node >=8 / Other ES2015 UMD/CommonJS environments 63 | 64 | ```javascript 65 | const { JsonStreamStringify } = require('json-stream-stringify'); // let module resolution decide UMD or CJS 66 | const { JsonStreamStringify } = require('json-stream-stringify/umd'); // force UMD 67 | const { JsonStreamStringify } = require('json-stream-stringify/cjs'); // force CJS 68 | ``` 69 | 70 | ### Using Node <=6 / Other ES5 UMD/CommonJS environments 71 | 72 | ```javascript 73 | const { JsonStreamStringify } = require('json-stream-stringify/polyfill'); 74 | const { JsonStreamStringify } = require('json-stream-stringify/umd/polyfill'); 75 | const { JsonStreamStringify } = require('json-stream-stringify/cjs/polyfill'); 76 | ``` 77 | 78 | **Note:** This library is primarily written for LTS versions of NodeJS. Other environments are not tested. 79 | **Note on non-NodeJS usage:** This module depends on node streams library. Any Streams3 compatible implementation should work - as long as it exports a `Readable` class, with instances that looks like readable streams. 80 | **Note on Polyfills:** I have taken measures to minify global pollution of polyfills but this library **does not load polyfills by default** because the polyfills modify native object prototypes and it goes against the [W3C recommendations](https://www.w3.org/2001/tag/doc/polyfills/#advice-for-library-and-framework-authors). 81 | 82 | ## API 83 | 84 | ### `new JsonStreamStringify(value[, replacer[, spaces[, cycle[, bufferSize=512]]]])` 85 | 86 | Streaming conversion of ``value`` to JSON string. 87 | 88 | #### Parameters 89 | 90 | - ``value`` ``Any`` 91 | Data to convert to JSON. 92 | 93 | - ``replacer`` Optional ``Function(key, value)`` or ``Array`` 94 | As a function the returned value replaces the value associated with the key. [Details](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter) 95 | As an array all other keys are filtered. [Details](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Example_with_an_array) 96 | 97 | - ``spaces`` Optional ``String`` or ``Number`` 98 | A String or Number object that's used to insert white space into the output JSON string for readability purposes. If this is a Number, it indicates the number of space characters to use as white space. If this is a String, the string is used as white space. If this parameter is not recognized as a finite number or valid string, no white space is used. 99 | 100 | - ``cycle`` Optional ``Boolean`` 101 | ``true`` enables cycling of cyclical structures and dags. 102 | To restore cyclical structures; use [Crockfords Retrocycle method](https://github.com/douglascrockford/JSON-js) on the parsed object (not included in this module). 103 | 104 | #### Returns 105 | 106 | - ``JsonStreamStringify`` object that exposes a [Streams3 interface](https://nodejs.org/api/stream.html#stream_class_stream_readable). 107 | 108 | ### jsonStreamStringify#path 109 | 110 | Get current path begin serialized. 111 | 112 | #### Returns 113 | 114 | - ``Array[String, Number]`` 115 | Array of path Strings (keys of objects) and Numbers (index into arrays). 116 | Can be transformed into an mpath with ``.join('.')``. 117 | Useful in conjunction with ``.on('error', ...)``, for figuring out what path may have caused the error. 118 | 119 | ## Complete Example 120 | 121 | ```javascript 122 | const { JsonStreamStringify } = require('json-stream-stringify'); 123 | 124 | const jsonStream = new JsonStreamStringify({ 125 | // Promises and Streams may resolve more promises and/or streams which will be consumed and processed into json output 126 | aPromise: Promise.resolve(Promise.resolve("text")), 127 | aStream: ReadableObjectStream({a:1}, 'str'), 128 | arr: [1, 2, Promise.resolve(3), Promise.resolve([4, 5]), ReadableStream('a', 'b', 'c')], 129 | date: new Date(2016, 0, 2) 130 | }); 131 | jsonStream.once('error', () => console.log('Error at path', jsonStream.stack.join('.'))); 132 | jsonStream.pipe(process.stdout); 133 | ``` 134 | 135 | Output (each line represents a write from jsonStreamStringify) 136 | 137 | ```text 138 | { 139 | "aPromise": 140 | "text" 141 | "aStream": 142 | [ 143 | { 144 | "a": 145 | 1 146 | } 147 | , 148 | "str" 149 | ] 150 | "arr": 151 | [ 152 | 1 153 | , 154 | 2 155 | , 156 | 3 157 | , 158 | [ 159 | 4 160 | , 161 | 5 162 | ] 163 | , 164 | " 165 | a 166 | b 167 | c 168 | " 169 | ], 170 | "date": 171 | "2016-01-01T23:00:00.000Z" 172 | } 173 | ``` 174 | 175 | ## Practical Example with Express + Mongoose 176 | 177 | ```javascript 178 | app.get('/api/users', (req, res, next) => { 179 | res.type('json'); // Required for proper handling by test frameworks and some clients 180 | new JsonStreamStringify(Users.find().stream()).pipe(res); 181 | }); 182 | ``` 183 | 184 | ### Why do I not get proper typings? (Missing .on(...), etc.) 185 | 186 | install ``@types/readable-stream`` or ``@types/node`` or create your own ``stream.d.ts`` that exports a ``Readable`` class. 187 | 188 | ## License 189 | 190 | [MIT](LICENSE) 191 | 192 | Copyright (c) 2016 Faleij [faleij@gmail.com](mailto:faleij@gmail.com) 193 | 194 | [npm-image]: http://img.shields.io/npm/v/json-stream-stringify.svg 195 | [npm-url]: https://npmjs.org/package/json-stream-stringify 196 | [downloads-image]: https://img.shields.io/npm/dm/json-stream-stringify.svg 197 | [downloads-url]: https://npmjs.org/package/json-stream-stringify 198 | [travis-image]: https://travis-ci.org/Faleij/json-stream-stringify.svg?branch=master 199 | [travis-url]: https://travis-ci.org/Faleij/json-stream-stringify 200 | [coveralls-image]: https://coveralls.io/repos/Faleij/json-stream-stringify/badge.svg?branch=master&service=github 201 | [coveralls-url]: https://coveralls.io/github/Faleij/json-stream-stringify?branch=master 202 | [license-image]: https://img.shields.io/badge/license-MIT-blue.svg 203 | [donate-image]: https://img.shields.io/badge/Donate-PayPal-green.svg 204 | [donate-url]: https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=faleij%40gmail%2ecom&lc=GB&item_name=faleij&item_number=jsonStreamStringify¤cy_code=SEK&bn=PP%2dDonationsBF%3abtn_donate_SM%2egif%3aNonHosted 205 | -------------------------------------------------------------------------------- /copy_to_lib/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-stream-stringify", 3 | "description": "JSON.Stringify as a readable stream", 4 | "version": "3.1.6", 5 | "license": "MIT", 6 | "author": "Faleij (https://github.com/faleij)", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/Faleij/json-stream-stringify.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/faleij/json-stream-stringify/issues" 13 | }, 14 | "files": [ 15 | "lib/**/*" 16 | ], 17 | "main": "./lib/cjs/index.js", 18 | "module": "./lib/esm/index.mjs", 19 | "umd:main": "./lib/umd/index.js", 20 | "browser": "./lib/umd/index.js", 21 | "types": "./lib/types/index.d.ts", 22 | "nyc": { 23 | "sourceMap": true, 24 | "instrument": true, 25 | "reporter": [ 26 | "lcov", 27 | "text" 28 | ], 29 | "extension": [ 30 | ".ts" 31 | ], 32 | "exclude": [ 33 | "**/*.d.ts", 34 | "test-src/**/*" 35 | ] 36 | }, 37 | "scripts": { 38 | "lint": "eslint \"src/**/*.ts\" && echo ✅ eslint passed", 39 | "build": "node --max-old-space-size=8192 node_modules/rollup/dist/bin/rollup -c rollup.config.js", 40 | "build:watch": "npm run build -- --watch", 41 | "test": "node node_modules/mocha/bin/mocha --require source-map-support/register -R spec -b \"test/*.spec.js\"", 42 | "coverage": "node node_modules/nyc/bin/nyc.js npm test" 43 | }, 44 | "devDependencies": { 45 | "@babel/cli": "^7.18.9", 46 | "@babel/core": "^7.18.9", 47 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 48 | "@babel/plugin-transform-runtime": "^7.18.9", 49 | "@babel/plugin-transform-typescript": "^7.18.8", 50 | "@babel/preset-env": "^7.18.9", 51 | "@babel/preset-typescript": "^7.18.6", 52 | "@rollup/plugin-babel": "^5.3.1", 53 | "@rollup/plugin-node-resolve": "^13.3.0", 54 | "@types/expect.js": "^0.3.29", 55 | "@types/mocha": "^9.1.1", 56 | "@types/readable-stream": "^2.3.14", 57 | "@typescript-eslint/eslint-plugin": "^5.31.0", 58 | "core-js": "^3.24.0", 59 | "coveralls": "3.1.1", 60 | "es-check": "^7.0.0", 61 | "eslint": "^8.20.0", 62 | "eslint-config-airbnb": "^19.0.4", 63 | "eslint-plugin-import": "^2.26.0", 64 | "expect.js": "0.3.1", 65 | "mocha": "^10.2.0", 66 | "nyc": "15.1.0", 67 | "rollup": "2.77.0", 68 | "rollup-plugin-dts": "^4.2.2", 69 | "rollup-plugin-typescript2": "^0.32.1", 70 | "source-map-support": "^0.5.21", 71 | "typescript": "^4.7.4" 72 | }, 73 | "volta": { 74 | "node": "22.9.0" 75 | }, 76 | "engines": { 77 | "node": ">=7.10.1" 78 | }, 79 | "exports": { 80 | ".": { 81 | "types": "./lib/types/index.d.ts", 82 | "import": "./lib/esm/index.mjs", 83 | "require": "./lib/umd/index.js" 84 | }, 85 | "./polyfill": { 86 | "types": "./lib/types/index.d.ts", 87 | "import": "./lib/esm/index.polyfill.mjs", 88 | "require": "./lib/umd/polyfill.js" 89 | }, 90 | "./esm/polyfill": { 91 | "types": "./lib/types/index.d.ts", 92 | "import": "./lib/esm/index.polyfill.mjs", 93 | "require": "./lib/esm/index.polyfill.mjs" 94 | }, 95 | "./esm": { 96 | "types": "./lib/types/index.d.ts", 97 | "import": "./lib/esm/index.mjs", 98 | "require": "./lib/esm/index.mjs" 99 | }, 100 | "./umd/polyfill": { 101 | "types": "./lib/types/index.d.ts", 102 | "import": "./lib/umd/polyfill.js", 103 | "require": "./lib/umd/polyfill.js" 104 | }, 105 | "./umd": { 106 | "types": "./lib/types/index.d.ts", 107 | "import": "./lib/umd/index.js", 108 | "require": "./lib/umd/index.js" 109 | }, 110 | "./cjs/polyfill": { 111 | "types": "./lib/types/index.d.ts", 112 | "import": "./lib/cjs/polyfill.js", 113 | "require": "./lib/cjs/polyfill.js" 114 | }, 115 | "./cjs": { 116 | "types": "./lib/types/index.d.ts", 117 | "import": "./lib/cjs/index.js", 118 | "require": "./lib/cjs/index.js" 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import dts from 'rollup-plugin-dts'; 5 | 6 | const input = './src/JsonStreamStringify.ts'; 7 | const tsconfigOverride = { compilerOptions: { declaration: false } }; 8 | const extensions = ['.ts', '.js', '.mjs']; 9 | const targets = { 10 | chrome: 55, 11 | node: '7.10.1', 12 | }; 13 | const envConfig = { 14 | forceAllTransforms: false, 15 | debug: false, 16 | useBuiltIns: 'usage', 17 | targets, 18 | corejs: 3, 19 | modules: false, 20 | }; 21 | const presets = [ 22 | ['@babel/preset-env', { 23 | ...envConfig, 24 | corejs: 3, 25 | useBuiltIns: 'usage', 26 | }], 27 | ]; 28 | const presetsNoPolly = [ 29 | ['@babel/preset-env', { 30 | ...envConfig, 31 | useBuiltIns: false, 32 | }], 33 | ]; 34 | 35 | function createExportConfig( 36 | output = { 37 | name: 'jsonStreamStringify', 38 | sourcemap: true, 39 | globals: { 40 | stream: 'stream', 41 | }, 42 | }, 43 | plugins = [ 44 | typescript({ 45 | useTsconfigDeclarationDir: true, 46 | }), 47 | nodeResolve({ 48 | jsnext: true, 49 | extensions, 50 | }), 51 | babel({ 52 | babelrc: false, 53 | extensions, 54 | presets, 55 | exclude: 'node_modules/**', 56 | babelHelpers: 'runtime', 57 | plugins: [ 58 | '@babel/plugin-transform-runtime', 59 | ], 60 | }), 61 | ], 62 | ) { 63 | return { 64 | input, 65 | output: { 66 | name: 'jsonStreamStringify', 67 | sourcemap: true, 68 | globals: { 69 | stream: 'stream', 70 | }, 71 | ...output, 72 | }, 73 | plugins, 74 | external(v) { 75 | return [ 76 | 'stream', 77 | 'core-js/', 78 | '@babel/runtime', 79 | ].some((el) => v === el || v.startsWith(el)); 80 | }, 81 | }; 82 | } 83 | 84 | const pluginsNoPolly = [ 85 | typescript({ tsconfigOverride }), 86 | nodeResolve({ 87 | jsnext: true, 88 | extensions, 89 | }), 90 | babel({ 91 | babelrc: false, 92 | extensions, 93 | presets: presetsNoPolly, 94 | exclude: 'node_modules/**', 95 | plugins: [], 96 | }), 97 | ]; 98 | 99 | export default [ 100 | createExportConfig({ 101 | file: './lib/esm/polyfill.mjs', 102 | format: 'es', 103 | }), 104 | // no polyfilled output 105 | createExportConfig({ 106 | file: './lib/esm/index.mjs', 107 | format: 'es', 108 | }, pluginsNoPolly), 109 | // no polyfilled output 110 | createExportConfig({ 111 | file: './lib/umd/index.js', 112 | format: 'umd', 113 | }, pluginsNoPolly), 114 | // no polyfilled output 115 | createExportConfig({ 116 | file: './lib/cjs/index.js', 117 | format: 'cjs', 118 | }, pluginsNoPolly), 119 | createExportConfig({ 120 | file: './lib/umd/polyfill.js', 121 | format: 'umd', 122 | }), 123 | createExportConfig({ 124 | file: './lib/cjs/polyfill.js', 125 | format: 'cjs', 126 | }), 127 | 128 | // generate typings 129 | { 130 | input, 131 | output: [{ file: 'lib/types/index.d.ts', format: 'es' }], 132 | plugins: [dts()], 133 | }, 134 | 135 | // compile tests 136 | { 137 | input: './test-src/JsonStreamStringify.esm.ts', 138 | output: { 139 | file: './test/JsonStreamStringify.esm.js', 140 | format: 'umd', 141 | name: 'jsonStreamStringify-esm', 142 | sourcemap: true, 143 | globals: { 144 | stream: 'stream', 145 | }, 146 | }, 147 | plugins: [ 148 | nodeResolve({ 149 | jsnext: true, 150 | extensions, 151 | }), 152 | typescript({ 153 | tsconfigOverride: { 154 | compilerOptions: { 155 | ...tsconfigOverride.compilerOptions, 156 | module: 'ESNext', 157 | }, 158 | }, 159 | }), 160 | babel({ 161 | babelrc: false, 162 | extensions, 163 | presets: [], 164 | exclude: 'node_modules/**', 165 | }), 166 | ], 167 | external(v) { 168 | return !(/([\\/]test-src[\\/])|(^.\/)|(^\0)/).test(v); 169 | }, 170 | }, 171 | { 172 | input: './test-src/JsonStreamStringify.spec.ts', 173 | output: { 174 | dir: './test', 175 | format: 'cjs', 176 | name: 'jsonStreamStringify', 177 | sourcemap: true, 178 | globals: { 179 | stream: 'stream', 180 | }, 181 | }, 182 | plugins: [ 183 | nodeResolve({ 184 | jsnext: true, 185 | extensions, 186 | }), 187 | typescript({ tsconfigOverride }), 188 | babel({ 189 | babelrc: false, 190 | extensions, 191 | presets, 192 | exclude: 'node_modules/**', 193 | }), 194 | ], 195 | external(v) { 196 | return !(/([\\/]test-src[\\/])|(^.\/)|(^\0)/).test(v) || v.includes('JsonStreamStringify.dynamic.'); 197 | }, 198 | }, 199 | ]; 200 | -------------------------------------------------------------------------------- /src/JsonStreamStringify.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { Readable } from 'stream'; 3 | 4 | // eslint-disable-next-line no-control-regex, no-misleading-character-class 5 | const rxEscapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; 6 | 7 | // table of character substitutions 8 | const meta = { 9 | '\b': '\\b', 10 | '\t': '\\t', 11 | '\n': '\\n', 12 | '\f': '\\f', 13 | '\r': '\\r', 14 | '"': '\\"', 15 | '\\': '\\\\', 16 | }; 17 | 18 | function isReadableStream(value): boolean { 19 | return typeof value.read === 'function' 20 | && typeof value.pause === 'function' 21 | && typeof value.resume === 'function' 22 | && typeof value.pipe === 'function' 23 | && typeof value.once === 'function' 24 | && typeof value.removeListener === 'function'; 25 | } 26 | 27 | enum Types { 28 | Array, 29 | Object, 30 | ReadableString, 31 | ReadableObject, 32 | Primitive, 33 | Promise, 34 | } 35 | 36 | function getType(value): Types { 37 | if (!value) return Types.Primitive; 38 | if (typeof value.then === 'function') return Types.Promise; 39 | if (isReadableStream(value)) return value._readableState.objectMode ? Types.ReadableObject : Types.ReadableString; 40 | if (Array.isArray(value)) return Types.Array; 41 | if (typeof value === 'object' || value instanceof Object) return Types.Object; 42 | return Types.Primitive; 43 | } 44 | 45 | function escapeString(string) { 46 | // Modified code, original code by Douglas Crockford 47 | // Original: https://github.com/douglascrockford/JSON-js/blob/master/json2.js 48 | 49 | // If the string contains no control characters, no quote characters, and no 50 | // backslash characters, then we can safely slap some quotes around it. 51 | // Otherwise we must also replace the offending characters with safe escape 52 | // sequences. 53 | 54 | return string.replace(rxEscapable, (a) => { 55 | const c = meta[a]; 56 | return typeof c === 'string' ? c : `\\u${a.charCodeAt(0).toString(16).padStart(4, '0')}`; 57 | }); 58 | } 59 | 60 | let primitiveToJSON: (value: any) => string; 61 | 62 | if (global?.JSON?.stringify instanceof Function) { 63 | try { 64 | if (JSON.stringify(global.BigInt ? global.BigInt('123') : '') !== '123') throw new Error(); 65 | primitiveToJSON = JSON.stringify; 66 | } catch (err) { 67 | // Add support for bigint for primitiveToJSON 68 | // eslint-disable-next-line no-confusing-arrow 69 | primitiveToJSON = (value) => typeof value === 'bigint' ? String(value) : JSON.stringify(value); 70 | } 71 | } else { 72 | primitiveToJSON = (value) => { 73 | switch (typeof value) { 74 | case 'string': 75 | return `"${escapeString(value)}"`; 76 | case 'number': 77 | return Number.isFinite(value) ? String(value) : 'null'; 78 | case 'bigint': 79 | return String(value); 80 | case 'boolean': 81 | return value ? 'true' : 'false'; 82 | case 'object': 83 | if (!value) { 84 | return 'null'; 85 | } 86 | // eslint-disable-next-line no-fallthrough 87 | default: 88 | // This should never happen, I can't imagine a situation where this executes. 89 | // If you find a way, please open a ticket or PR 90 | throw Object.assign(new Error(`Not a primitive "${typeof value}".`), { value }); 91 | } 92 | }; 93 | } 94 | 95 | /* 96 | function quoteString(string: string) { 97 | return primitiveToJSON(String(string)); 98 | } 99 | */ 100 | 101 | const cache = new Map(); 102 | function quoteString(string: string) { 103 | const useCache = string.length < 10_000; 104 | // eslint-disable-next-line no-lonely-if 105 | if (useCache && cache.has(string)) { 106 | return cache.get(string); 107 | } 108 | const str = primitiveToJSON(String(string)); 109 | if (useCache) cache.set(string, str); 110 | return str; 111 | } 112 | 113 | function readAsPromised(stream: Readable, size?) { 114 | const value = stream.read(size); 115 | if (value === null && !(stream.readableEnded || (stream as any)._readableState?.ended)) { 116 | return new Promise((resolve, reject) => { 117 | const endListener = () => resolve(null); 118 | stream.once('end', endListener); 119 | stream.once('error', reject); 120 | stream.once('readable', () => { 121 | stream.removeListener('end', endListener); 122 | stream.removeListener('error', reject); 123 | readAsPromised(stream, size).then(resolve, reject); 124 | }); 125 | }); 126 | } 127 | return Promise.resolve(value); 128 | } 129 | 130 | interface Item { 131 | read(size?: number): Promise | void; 132 | depth?: number; 133 | value?: any; 134 | indent?: string; 135 | path?: (string | number)[]; 136 | type?: string; 137 | } 138 | 139 | enum ReadState { 140 | Inactive = 0, 141 | Reading, 142 | ReadMore, 143 | Consumed, 144 | } 145 | 146 | export class JsonStreamStringify extends Readable { 147 | item?: Item; 148 | indent?: string; 149 | root: Item; 150 | include: string[]; 151 | replacer: Function; 152 | visited: [] | WeakMap; 153 | 154 | constructor( 155 | input: any, 156 | replacer?: Function | any[] | undefined, 157 | spaces?: number | string | undefined, 158 | private cycle = false, 159 | private bufferSize = 512, 160 | ) { 161 | super({ encoding: 'utf8' }); 162 | 163 | const spaceType = typeof spaces; 164 | if (spaceType === 'number') { 165 | this.indent = ' '.repeat(spaces); 166 | } else if (spaceType === 'string') { 167 | this.indent = spaces; 168 | } 169 | 170 | const replacerType = typeof replacer; 171 | if (replacerType === 'object') { 172 | this.include = replacer as string[]; 173 | } else if (replacerType === 'function') { 174 | this.replacer = replacer as Function; 175 | } 176 | 177 | this.visited = cycle ? new WeakMap() : []; 178 | 179 | this.root = { 180 | value: { '': input }, 181 | depth: 0, 182 | indent: '', 183 | path: [], 184 | }; 185 | this.setItem(input, this.root, ''); 186 | } 187 | 188 | setItem(value, parent: Item, key: string | number = '') { 189 | // call toJSON where applicable 190 | if ( 191 | value 192 | && typeof value === 'object' 193 | && typeof value.toJSON === 'function' 194 | ) { 195 | value = value.toJSON(key); 196 | } 197 | 198 | // use replacer if applicable 199 | if (this.replacer) { 200 | value = this.replacer.call(parent.value, key, value); 201 | } 202 | 203 | // coerece functions and symbols into undefined 204 | if (value instanceof Function || typeof value === 'symbol') { 205 | value = undefined; 206 | } 207 | 208 | const type = getType(value); 209 | let path; 210 | 211 | // check for circular structure 212 | if (!this.cycle && type !== Types.Primitive) { 213 | if ((this.visited as any[]).some((v) => v === value)) { 214 | this.destroy(Object.assign(new Error('Converting circular structure to JSON'), { 215 | value, 216 | key, 217 | })); 218 | return; 219 | } 220 | (this.visited as any[]).push(value); 221 | } else if (this.cycle && type !== Types.Primitive) { 222 | path = (this.visited as WeakMap).get(value); 223 | if (path) { 224 | this._push(`{"$ref":"$${path.map((v) => `[${(Number.isInteger(v as number) ? v : escapeString(quoteString(v as string)))}]`).join('')}"}`); 225 | this.item = parent; 226 | return; 227 | } 228 | path = parent === this.root ? [] : parent.path.concat(key); 229 | (this.visited as WeakMap).set(value, path); 230 | } 231 | 232 | if (type === Types.Object) { 233 | this.setObjectItem(value, parent); 234 | } else if (type === Types.Array) { 235 | this.setArrayItem(value, parent); 236 | } else if (type === Types.Primitive) { 237 | if (parent !== this.root && typeof key === 'string') { 238 | // (parent).write(key, primitiveToJSON(value)); 239 | if (value === undefined) { 240 | // clear prePush buffer 241 | // this.prePush = ''; 242 | } else { 243 | this._push(primitiveToJSON(value)); 244 | } 245 | // undefined values in objects should be rejected 246 | } else if (value === undefined && typeof key === 'number') { 247 | // undefined values in array should be null 248 | this._push('null'); 249 | } else if (value === undefined) { 250 | // undefined values should be ignored 251 | } else { 252 | this._push(primitiveToJSON(value)); 253 | } 254 | this.item = parent; 255 | return; 256 | } else if (type === Types.Promise) { 257 | this.setPromiseItem(value, parent, key); 258 | } else if (type === Types.ReadableString) { 259 | this.setReadableStringItem(value, parent); 260 | } else if (type === Types.ReadableObject) { 261 | this.setReadableObjectItem(value, parent); 262 | } 263 | 264 | this.item.value = value; 265 | this.item.depth = parent.depth + 1; 266 | if (this.indent) this.item.indent = this.indent.repeat(this.item.depth); 267 | this.item.path = path; 268 | } 269 | 270 | setReadableStringItem(input: Readable, parent: Item) { 271 | if (input.readableEnded || (input as any)._readableState?.endEmitted) { 272 | this.emit('error', new Error('Readable Stream has ended before it was serialized. All stream data have been lost'), input, parent.path); 273 | } else if (input.readableFlowing || (input as any)._readableState?.flowing) { 274 | input.pause(); 275 | this.emit('error', new Error('Readable Stream is in flowing mode, data may have been lost. Trying to pause stream.'), input, parent.path); 276 | } 277 | const that = this; 278 | this.prePush = '"'; 279 | this.item = { 280 | type: 'readable string', 281 | async read(size: number) { 282 | try { 283 | const data = await readAsPromised(input, size); 284 | if (data === null) { 285 | that._push('"'); 286 | that.item = parent; 287 | that.unvisit(input); 288 | return; 289 | } 290 | if (data) that._push(escapeString(data.toString())); 291 | } catch (err) { 292 | that.emit('error', err); 293 | that.destroy(); 294 | } 295 | }, 296 | }; 297 | } 298 | 299 | setReadableObjectItem(input: Readable, parent: Item) { 300 | if (input.readableEnded || (input as any)._readableState?.endEmitted) { 301 | this.emit('error', new Error('Readable Stream has ended before it was serialized. All stream data have been lost'), input, parent.path); 302 | } else if (input.readableFlowing || (input as any)._readableState?.flowing) { 303 | input.pause(); 304 | this.emit('error', new Error('Readable Stream is in flowing mode, data may have been lost. Trying to pause stream.'), input, parent.path); 305 | } 306 | const that = this; 307 | this._push('['); 308 | let first = true; 309 | let i = 0; 310 | const item = { 311 | type: 'readable object', 312 | async read() { 313 | try { 314 | let out = ''; 315 | const data = await readAsPromised(input); 316 | if (data === null) { 317 | if (i && that.indent) { 318 | out += `\n${parent.indent}`; 319 | } 320 | out += ']'; 321 | that._push(out); 322 | that.item = parent; 323 | that.unvisit(input); 324 | return; 325 | } 326 | if (first) first = false; 327 | else out += ','; 328 | if (that.indent) out += `\n${item.indent}`; 329 | that.prePush = out; 330 | that.setItem(data, item, i); 331 | i += 1; 332 | } catch (err) { 333 | that.emit('error', err); 334 | that.destroy(); 335 | } 336 | }, 337 | }; 338 | this.item = item; 339 | } 340 | 341 | setPromiseItem(input: Promise, parent: Item, key) { 342 | const that = this; 343 | let read = false; 344 | this.item = { 345 | async read() { 346 | if (read) return; 347 | try { 348 | read = true; 349 | that.setItem(await input, parent, key); 350 | } catch (err) { 351 | that.emit('error', err); 352 | that.destroy(); 353 | } 354 | }, 355 | }; 356 | } 357 | 358 | setArrayItem(input: any[], parent: any) { 359 | // const entries = input.slice().reverse(); 360 | let i = 0; 361 | const len = input.length; 362 | let first = true; 363 | const that = this; 364 | const item: Item = { 365 | read() { 366 | let out = ''; 367 | let wasFirst = false; 368 | if (first) { 369 | first = false; 370 | wasFirst = true; 371 | if (!len) { 372 | that._push('[]'); 373 | that.unvisit(input); 374 | that.item = parent; 375 | return; 376 | } 377 | out += '['; 378 | } 379 | const entry = input[i]; 380 | if (i === len) { 381 | if (that.indent) out += `\n${parent.indent}`; 382 | out += ']'; 383 | that._push(out); 384 | that.item = parent; 385 | that.unvisit(input); 386 | return; 387 | } 388 | if (!wasFirst) out += ','; 389 | if (that.indent) out += `\n${item.indent}`; 390 | that._push(out); 391 | that.setItem(entry, item, i); 392 | i += 1; 393 | }, 394 | }; 395 | this.item = item; 396 | } 397 | 398 | unvisit(item) { 399 | if (this.cycle) return; 400 | const _i = (this.visited as any[]).indexOf(item); 401 | if (_i > -1) (this.visited as any[]).splice(_i, 1); 402 | } 403 | 404 | objectItem?: any; 405 | setObjectItem(input: Record, parent = undefined) { 406 | const keys = Object.keys(input); 407 | let i = 0; 408 | const len = keys.length; 409 | let first = true; 410 | const that = this; 411 | const { include } = this; 412 | let hasItems = false; 413 | let key; 414 | const item: Item = { 415 | read() { 416 | if (i === 0) that._push('{'); 417 | if (i === len) { 418 | that.objectItem = undefined; 419 | if (!hasItems) { 420 | that._push('}'); 421 | } else { 422 | that._push(`${that.indent ? `\n${parent.indent}` : ''}}`); 423 | } 424 | that.item = parent; 425 | that.unvisit(input); 426 | return; 427 | } 428 | key = keys[i]; 429 | if (include?.indexOf?.(key) === -1) { 430 | // replacer array excludes this key 431 | i += 1; 432 | return; 433 | } 434 | that.objectItem = item; 435 | i += 1; 436 | that.setItem(input[key], item, key); 437 | }, 438 | write() { 439 | const out = `${hasItems && !first ? ',' : ''}${item.indent ? `\n${item.indent}` : ''}${quoteString(key)}:${that.indent ? ' ' : ''}`; 440 | first = false; 441 | hasItems = true; 442 | that.objectItem = undefined; 443 | return out; 444 | }, 445 | }; 446 | this.item = item; 447 | } 448 | 449 | buffer = ''; 450 | bufferLength = 0; 451 | pushCalled = false; 452 | 453 | readSize = 0; 454 | /** if set, this string will be prepended to the next _push call, if the call output is not empty, and set to undefined */ 455 | prePush?: string; 456 | private _push(data) { 457 | const out = (this.objectItem ? this.objectItem.write() : '') + data; 458 | if (this.prePush && out.length) { 459 | this.buffer += this.prePush; 460 | this.prePush = undefined; 461 | } 462 | this.buffer += out; 463 | if (this.buffer.length >= this.bufferSize) { 464 | this.pushCalled = !this.push(this.buffer); 465 | this.buffer = ''; 466 | this.bufferLength = 0; 467 | return false; 468 | } 469 | return true; 470 | } 471 | 472 | readState: ReadState = ReadState.Inactive; 473 | async _read(size?: number): Promise { 474 | if (this.readState === ReadState.Consumed) return; 475 | if (this.readState !== ReadState.Inactive) { 476 | this.readState = ReadState.ReadMore; 477 | return; 478 | } 479 | this.readState = ReadState.Reading; 480 | this.pushCalled = false; 481 | let p; 482 | while (!this.pushCalled && this.item !== this.root && this.buffer !== undefined) { 483 | p = this.item.read(size); 484 | // eslint-disable-next-line no-await-in-loop 485 | if (p) await p; 486 | } 487 | if (this.buffer === undefined) return; 488 | if (this.item === this.root) { 489 | if (this.buffer.length) this.push(this.buffer); 490 | this.push(null); 491 | this.readState = ReadState.Consumed; 492 | this.cleanup(); 493 | return; 494 | } 495 | if (this.readState === ReadState.ReadMore) { 496 | this.readState = ReadState.Inactive; 497 | await this._read(size); 498 | return; 499 | } 500 | this.readState = ReadState.Inactive; 501 | } 502 | 503 | private cleanup() { 504 | this.readState = ReadState.Consumed; 505 | this.buffer = undefined; 506 | this.visited = undefined; 507 | this.item = undefined; 508 | this.root = undefined; 509 | this.prePush = undefined; 510 | } 511 | 512 | destroy(error?: Error): this { 513 | if (error) this.emit('error', error); 514 | super.destroy?.(); 515 | this.cleanup(); 516 | return this; 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /test-src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'mocha', 4 | ], 5 | env: { 6 | mocha: true, 7 | }, 8 | rules: { 9 | 'no-sparse-arrays': 'off', 10 | 'import/first': 'off', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /test-src/JsonStreamStringify.esm.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import expect from 'expect.js'; 3 | 4 | it('esm should export JsonStreamStringify', async () => { 5 | const esm = await import('../lib/esm/index.mjs'); 6 | expect(esm).to.have.property('JsonStreamStringify'); 7 | expect(esm.JsonStreamStringify.name).to.be('JsonStreamStringify'); 8 | }); 9 | -------------------------------------------------------------------------------- /test-src/JsonStreamStringify.spec.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { Readable, PassThrough, Writable } from 'stream'; 4 | // tslint:disable-next-line:import-name 5 | import expect from 'expect.js'; 6 | import { JsonStreamStringify } from './JsonStreamStringify'; 7 | 8 | // create an object that emits an error (err) when serialized to json 9 | function emitError(err: Error) { 10 | return { 11 | toJSON() { 12 | throw err; 13 | }, 14 | }; 15 | } 16 | 17 | function createTest(input, expected, ...args) { 18 | return () => new Promise<{ jsonStream: InstanceType }>((resolve, reject) => { 19 | let str = ''; 20 | const jsonStream = new JsonStreamStringify(input, ...args) 21 | .once('end', () => { 22 | try { 23 | expect(str).to.equal(expected); 24 | } catch (err) { 25 | reject(err); 26 | return; 27 | } 28 | resolve({ jsonStream }); 29 | }) 30 | .once('error', err => { 31 | reject(Object.assign(err, { jsonStream })) 32 | }) 33 | .on('data', (data) => { 34 | str += data.toString(); 35 | }); 36 | }); 37 | } 38 | 39 | function readableStream(...args) { 40 | const stream = new Readable({ 41 | objectMode: args.some(v => typeof v !== 'string'), 42 | }); 43 | stream._read = () => { 44 | if (!args.length) { 45 | return stream.push(null); 46 | } 47 | const v = args.shift(); 48 | if (v instanceof Error) return stream.emit('error', v); 49 | return stream.push(v); 50 | }; 51 | return stream; 52 | } 53 | 54 | describe('JsonStreamStringify', function () { 55 | this.timeout(10000); 56 | 57 | after(() => { 58 | // test does not exit cleanly :/ 59 | setTimeout(() => process.exit(), 500).unref(); 60 | }); 61 | 62 | const date = new Date(); 63 | 64 | it('null should be null', createTest(null, 'null')); 65 | 66 | it('Infinity should be null', createTest(Infinity, 'null')); 67 | 68 | it('date should be date.toJSON()', createTest(date, `"${date.toJSON()}"`)); 69 | 70 | it('true should be true', createTest(true, 'true')); 71 | 72 | it('Symbol should be ""', createTest(Symbol('test'), '')); 73 | 74 | it('1 should be 1', createTest(1, '1')); 75 | 76 | it('1 should be 2', createTest(1, '2', () => 2)); 77 | 78 | it('"\\n" should be "\\\\n"', createTest('\n', '"\\n"')); 79 | 80 | it('"漢字" should be "漢字"', createTest('漢字', '"漢字"')); 81 | 82 | // it('"\\u009f" should be "\\\\u009f"', createTest('\u009f', '"\\u009f"')); 83 | 84 | it('{} should be {}', createTest({}, '{}')); 85 | 86 | it('/regex/gi should be {}', createTest(/regex/gi, '{}')); 87 | 88 | it('{undefined:null} should be {"undefined":null}', createTest({ undefined: null }, '{"undefined":null}')); 89 | 90 | it('{"":null} should be {"":null}', createTest({ "": null }, '{"":null}')); 91 | 92 | it('{a:undefined} should be {}', createTest({ a: undefined }, '{}')); 93 | 94 | it('{a:null} should be {"a":null}', createTest({ a: null }, '{"a":null}')); 95 | 96 | if (typeof BigInt !== 'undefined') { 97 | it('{a:0n} should be {"a":0}', createTest({ a: BigInt(0) }, '{"a":0}')); 98 | } 99 | 100 | it('{a:undefined} should be {"a":1}', createTest( 101 | { 102 | a: undefined, 103 | }, 104 | '{"a":1}', 105 | (k, v) => { 106 | if (k) { 107 | expect(k).to.be('a'); 108 | expect(v).to.be(undefined); 109 | return 1; 110 | } 111 | return v; 112 | })); 113 | 114 | it('{a:1, b:2} should be {"a":1}', createTest( 115 | { 116 | a: 1, 117 | b: 2, 118 | }, 119 | '{"a":1}', 120 | (k, v) => { 121 | if (k === 'a') { 122 | expect(v).to.be(1); 123 | return v; 124 | } 125 | if (k === 'b') { 126 | expect(v).to.be(2); 127 | return undefined; 128 | } 129 | if (k === '') return v; 130 | expect(['a', 'b', '']).to.contain(k); 131 | return v; 132 | })); 133 | 134 | it('{a:1,b:2} should be {"b":2}', createTest( 135 | { 136 | a: 1, 137 | b: 2, 138 | }, 139 | '{"b":2}', 140 | ['b'])); 141 | 142 | it('{a:1} should be {"a":1}', createTest( 143 | { 144 | a: 1, 145 | }, 146 | '{"a":1}')); 147 | 148 | it('{a:1,b:undefined} should be {"a":1}', createTest( 149 | { 150 | a: 1, 151 | b: undefined, 152 | }, 153 | '{"a":1}')); 154 | 155 | it('{a:1,b:Promise(undefined)} should be {"a":1}', createTest( 156 | { 157 | a: 1, 158 | b: Promise.resolve(undefined), 159 | }, 160 | '{"a":1}')); 161 | 162 | it('{a:function(){}, b: "b"} should be {"b":"b"}', createTest( 163 | { 164 | // tslint:disable-next-line:function-name 165 | a() {}, 166 | b: 'b', 167 | }, 168 | '{"b":"b"}')); 169 | 170 | it('[function(){}] should be [null]', createTest([function a() {}], '[null]')); 171 | 172 | it('[function(){}, undefined] should be [null,null]', createTest([function a() {}, undefined], '[null,null]')); 173 | 174 | it('{a:date} should be {"a":date.toJSON()}', createTest( 175 | { 176 | a: date, 177 | }, 178 | `{"a":"${date.toJSON()}"}`)); 179 | 180 | it('({a:1,b:{c:2}}) should be {"a":1,"b":{"c":2}}', createTest( 181 | { 182 | a: 1, 183 | b: { 184 | c: 2, 185 | }, 186 | }, 187 | '{"a":1,"b":{"c":2}}')); 188 | 189 | it('{a:[1], "b": 2} should be {"a":[1],"b":2}', createTest( 190 | { 191 | a: [1], 192 | b: 2, 193 | }, 194 | '{"a":[1],"b":2}')); 195 | 196 | it('{a: array, "b": array } should be {"a":[],"b":[]}', () => { 197 | const array = []; 198 | const data = { a: array, b: array }; 199 | return createTest(data, '{"a":[],"b":[]}')(); 200 | }); 201 | 202 | it('[] should be []', createTest([], '[]')); 203 | 204 | it('[[[]],[[]]] should be [[[]],[[]]]', createTest( 205 | [ 206 | [ 207 | [], 208 | ], 209 | [ 210 | [], 211 | ], 212 | ], 213 | '[[[]],[[]]]')); 214 | 215 | it('[1, undefined, 2] should be [1,null,2]', createTest([1, undefined, 2], '[1,null,2]')); 216 | 217 | it('[1, , 2] should be [1,null,2]', createTest([1, , 2], '[1,null,2]')); 218 | 219 | it('[1,\'a\'] should be [1,"a"]', createTest([1, 'a'], '[1,"a"]')); 220 | 221 | it('Promise(1) should be 1', createTest(Promise.resolve(1), '1')); 222 | 223 | it('Promise(Promise(1)) should be 1', createTest(Promise.resolve(Promise.resolve(1)), '1')); 224 | 225 | it('Promise(fakePromise(Promise.resolve(1))) should be 1', createTest( 226 | { 227 | then(fn) { 228 | return fn(Promise.resolve(1)); 229 | }, 230 | }, 231 | '1')); 232 | 233 | it('Promise.reject(Error) should emit Error', () => { 234 | const err = new Error('should emit error'); 235 | return createTest(new Promise((resolve, reject) => reject(err)), '')() 236 | .then( 237 | () => new Error('exepected error to be emitted'), 238 | err1 => expect(err1).to.be(err), 239 | ); 240 | }); 241 | 242 | it('{a:Promise(1)} should be {"a":1}', createTest({ a: Promise.resolve(1) }, '{"a":1}')); 243 | 244 | it('readableStream(1) should be [1]', createTest(readableStream(1), '[1]')); 245 | 246 | it('Promise(readableStream(1)) should be [1]', createTest(Promise.resolve(readableStream(1)), '[1]')); 247 | 248 | it('{a:[readableStream(1, Error, 2)]} should emit Error', () => { 249 | const err = new Error('should emit error'); 250 | return createTest({ 251 | a: [readableStream(1, emitError(err), 2)], 252 | }, '')() 253 | .then(() => new Error('exepected error to be emitted'), (err1) => { 254 | expect(err1).to.be(err); 255 | }); 256 | }); 257 | 258 | it('readableStream(1, 2, 3, 4, 5, 6, 7).resume() should emit Error', () => { 259 | return createTest(readableStream(1, 2, 3, 4, 5, 6, 7).resume(), '[1,2,3,4,5,6,7]')() 260 | .then(() => new Error('exepected error to be emitted'), (err) => { 261 | expect(err.message).to.be('Readable Stream is in flowing mode, data may have been lost. Trying to pause stream.'); 262 | }); 263 | }); 264 | 265 | it('EndedReadableStream(1, 2, 3, 4, 5, 6, 7) should emit Error', () => { 266 | const stream = readableStream(1, 2, 3, 4, 5, 6, 7); 267 | return createTest(new Promise(resolve => stream.once('end', () => resolve(stream)).resume()), '[1,2,3,4,5,6,7]')() 268 | .then(() => new Error('exepected error to be emitted'), (err) => { 269 | expect(err.message).to.be('Readable Stream has ended before it was serialized. All stream data have been lost'); 270 | }); 271 | }); 272 | 273 | it('{a:ReadableStream(1,2,3)} should be {"a":[1,2,3]}', createTest({ 274 | a: readableStream(1, 2, 3), 275 | }, '{"a":[1,2,3]}')); 276 | 277 | it('readableStream(\'a\', \'b\', \'c\') should be "abc"', createTest(readableStream('a', 'b', 'c'), '"abc"')); 278 | 279 | it('readableStream(\'a\', \'b\', \'c\') should be "abc"', () => { 280 | const stream = new Readable(); 281 | const args = ['a', 'b', 'c']; 282 | Object.assign(stream, { 283 | firstRead: true, 284 | // tslint:disable-next-line:function-name 285 | _read() { 286 | setTimeout(() => { 287 | if (!args.length) return stream.push(null); 288 | const v = args.shift(); 289 | return stream.push(v); 290 | }, 1); 291 | }, 292 | }); 293 | return createTest(stream, '"abc"')(); 294 | }); 295 | 296 | it('readableStream({}, \'a\', undefined, \'c\') should be [{},"a",null,"c"]', createTest(readableStream({}, 'a', undefined, 'c'), '[{},"a",null,"c"]')); 297 | 298 | it(`{ a: readableStream({name: 'name', date: date }) } should be {"a":[{"name":"name","date":"${date.toJSON()}"}]}`, createTest( 299 | { 300 | a: readableStream({ 301 | name: 'name', 302 | // tslint:disable-next-line:object-shorthand-properties-first 303 | date, 304 | }), 305 | }, 306 | `{"a":[{"name":"name","date":"${date.toJSON()}"}]}`)); 307 | 308 | it(`{ a: readableStream({name: 'name', arr: [], date: date }) } should be {"a":[{"name":"name","arr":[],"date":"${date.toJSON()}"}]}`, createTest( 309 | { 310 | a: readableStream({ 311 | name: 'name', 312 | arr: [], 313 | // tslint:disable-next-line:object-shorthand-properties-first 314 | date, 315 | }), 316 | }, 317 | `{"a":[{"name":"name","arr":[],"date":"${date.toJSON()}"}]}`)); 318 | 319 | describe('space option', () => { 320 | it('{ a: 1 } should be {\\n "a": 1\\n}', createTest({ a: 1 }, '{\n "a": 1\n}', undefined, 2)); 321 | 322 | it('[1] should be [\\n 1\\n ]', createTest([1], '[\n 1\n]', undefined, 2)); 323 | 324 | it('[1] should be [\\na1\\na]', createTest([1], '[\na1\n]', undefined, 'a')); 325 | }); 326 | 327 | describe('cyclic structure', () => { 328 | const cyclicData0 : any = {}; 329 | cyclicData0.a = cyclicData0; 330 | it('{ a: a } should be {"a":{"$ref":"$"}}', () => createTest(cyclicData0, '{"a":{"$ref":"$"}}', undefined, undefined, true)); 331 | 332 | it('{ a: [], b: [] } should be {"a":[],"b":[]}', () => { 333 | const cyclicData : any = { a: [], b: [] }; 334 | return createTest(cyclicData, '{"a":[],"b":[]}', undefined, undefined, true)(); 335 | }); 336 | 337 | it('{ a: a, b: a } should be {"a":[],"b":{"$ref":"$"}}', () => { 338 | const cyclicData : any = { a: [] }; 339 | cyclicData.b = cyclicData.a; 340 | return createTest(cyclicData, '{"a":[],"b":{"$ref":"$[\\"a\\"]"}}', undefined, undefined, true)(); 341 | }); 342 | 343 | const cyclicData1 : any = {}; 344 | cyclicData1.a = cyclicData1; 345 | cyclicData1.b = [cyclicData1, { 346 | a: cyclicData1, 347 | }]; 348 | cyclicData1.b[3] = readableStream(cyclicData1.b[1]); 349 | it('{a: a, b: [a, { a: a },,readableStream(b.1)]} should be {"a":{"$ref":"$"},"b":[{"$ref":"$"},{"a":{"$ref":"$"}},null,[{"$ref":"$[\\"b\\"][1]"}]]}', 350 | createTest(cyclicData1, '{"a":{"$ref":"$"},"b":[{"$ref":"$"},{"a":{"$ref":"$"}},null,[{"$ref":"$[\\"b\\"][1]"}]]}', undefined, undefined, true)); 351 | 352 | const cyclicData2 : any = {}; 353 | const data2 = { 354 | a: 'deep', 355 | }; 356 | cyclicData2.a = Promise.resolve({ 357 | b: data2, 358 | }); 359 | cyclicData2.b = data2; 360 | it('{ a: Promise({ b: { a: \'deep\' } }), b: a.b } should be {"a":{"b":{"a":"deep"}},"b":{"$ref":"$[\\"a\\"][\\"b\\"]"}}', 361 | createTest(cyclicData2, '{"a":{"b":{"a":"deep"}},"b":{"$ref":"$[\\"a\\"][\\"b\\"]"}}', undefined, undefined, true)); 362 | }); 363 | 364 | describe('circular structure', () => { 365 | const cyclicData0: any = {}; 366 | cyclicData0.a = cyclicData0; 367 | it('{ a: $ } should emit error', () => createTest(cyclicData0, '')() 368 | .then( 369 | () => new Error('should emit error'), 370 | (err) => { 371 | expect(err.message).to.be('Converting circular structure to JSON'); 372 | }, 373 | )); 374 | 375 | it('Promise({ a: Promise($) }) should emit error', () => { 376 | const cyclicData1: any = {}; 377 | cyclicData1.a = Promise.resolve(cyclicData1); 378 | return createTest(Promise.resolve(cyclicData1), '')() 379 | .then( 380 | () => new Error('should emit error'), 381 | (err) => { 382 | expect(err.message).to.be('Converting circular structure to JSON'); 383 | }, 384 | ); 385 | }); 386 | 387 | it('{ a: readableStream($) } should emit error', () => { 388 | const cyclicData2: any = {}; 389 | cyclicData2.a = readableStream(cyclicData2); 390 | return createTest(readableStream(cyclicData2), '')() 391 | .then( 392 | () => new Error('should emit error'), 393 | (err) => { 394 | expect(err.message).to.be('Converting circular structure to JSON'); 395 | }, 396 | ); 397 | }); 398 | }); 399 | 400 | describe('decycle should not be active', () => { 401 | const a = { 402 | foo: 'bar', 403 | }; 404 | const arr = [a, a]; 405 | it('[a, a] should be [{"foo":"bar"},{"foo":"bar"}]', createTest(arr, '[{"foo":"bar"},{"foo":"bar"}]')); 406 | }); 407 | 408 | it('bad reader', (resolve) => { 409 | const p = new PassThrough({ objectMode: true }); 410 | let c = 0; 411 | p.write(c++); 412 | const a = new JsonStreamStringify(p); 413 | let out = ''; 414 | a.once('end', () => { 415 | expect(out).to.be('[0,1,2,3]'); 416 | resolve(); 417 | }) 418 | a.on('data', (data) => { 419 | out += data.toString(); 420 | }).pause(); 421 | let ended = false; 422 | read(); 423 | function read() { 424 | if (a.readableEnded) return; 425 | for (let i = 0; i < 10; i++) { 426 | a._read(); // simulate bad forced read 427 | a.read(); // legitimate read call 428 | a._read(); // simulate bad forced read 429 | if (!(p.writableEnded || ended) && i === 8) p.write(c++); 430 | a._read(); // simulate bad forced read 431 | } 432 | if (!(p.writableEnded || ended) && c > 3) { 433 | ended = true; 434 | p.end(); 435 | // p.read(); 436 | setTimeout(read, 10); 437 | return; 438 | } 439 | setImmediate(read); 440 | } 441 | }); 442 | 443 | it('prePush', (cb) => { 444 | const p = new PassThrough({ objectMode: true }); 445 | const a = new JsonStreamStringify(p); 446 | (a as any).bufferSize = Infinity; 447 | a.prePush = ','; 448 | (a as any)._push(''); 449 | expect(a.buffer).to.be('['); 450 | a.prePush = undefined; 451 | (a as any)._push('"a"'); 452 | a.prePush = ','; 453 | (a as any)._push(''); 454 | expect(a.buffer).to.be('["a"'); 455 | (a as any)._push('"b"'); 456 | a.prePush = ','; 457 | (a as any)._push(''); 458 | expect(a.buffer).to.be('["a","b"'); 459 | a.prePush = ''; 460 | p.end(async () => { 461 | try { 462 | await a.item?.read(); 463 | expect(a.buffer).to.be('["a","b"]'); 464 | cb(); 465 | } catch(err) { 466 | cb(err); 467 | } 468 | }); 469 | }); 470 | 471 | }); 472 | -------------------------------------------------------------------------------- /test-src/JsonStreamStringify.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import type * as Module from '..'; 4 | import expect from 'expect.js'; 5 | 6 | const nodeVersion = parseInt(process.version.split('.')[0].slice(1), 10); 7 | 8 | type ModuleType = typeof Module; 9 | // tslint:disable:variable-name 10 | const { JsonStreamStringify }: ModuleType = (nodeVersion >= 8 ? require('../lib/umd/index.js') : require('../lib/umd/polyfill.js')); 11 | 12 | describe('JsonStreamStringify package', () => { 13 | if (nodeVersion === 16) { 14 | require('./JsonStreamStringify.esm.js'); 15 | } 16 | 17 | it('umd should export JsonStreamStringify', async () => { 18 | const { JsonStreamStringify: { name } }: ModuleType = require('../lib/umd/index.js'); 19 | expect(name).to.be('JsonStreamStringify'); 20 | }); 21 | 22 | it('cjs should export JsonStreamStringify', async () => { 23 | const { JsonStreamStringify: { name } }: ModuleType = require('../lib/cjs/index.js'); 24 | expect(name).to.be('JsonStreamStringify'); 25 | }); 26 | }); 27 | 28 | export { JsonStreamStringify }; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2015", 4 | "target": "ES2022", 5 | "noImplicitAny": false, 6 | "preserveConstEnums": true, 7 | "sourceMap": true, 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true, 11 | "types": [ 12 | "readable-stream", 13 | "mocha" 14 | ], 15 | "outDir": "lib" 16 | }, 17 | "include": [ 18 | "src/**/*.ts", 19 | "typings" 20 | ], 21 | "exclude": [ 22 | "node_modules" 23 | ] 24 | } --------------------------------------------------------------------------------