├── .all-contributorsrc ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── bug_types.yml │ └── feature_request.yml ├── pull_request_template.md └── workflows │ └── workflow.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── ava.config.js ├── eslint.config.js ├── gulpfile.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── array.js ├── array.test.js ├── cycle.js ├── cycle.test.js ├── get.js ├── get.test.js ├── indices.js ├── indices.test.js ├── is_object.js ├── key.js ├── key.test.js ├── main.d.ts ├── main.js ├── main.test-d.ts ├── main.test.js ├── object.js ├── object.test.js ├── prop.js ├── size.js ├── size.test.js ├── to_json.js ├── to_json.test.js ├── type.js ├── type.test.js ├── uncaught.js └── uncaught.test.js └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "safe-json-value", 3 | "projectOwner": "ehmicky", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "linkToUsage": false, 12 | "contributors": [ 13 | { 14 | "login": "ehmicky", 15 | "name": "ehmicky", 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/8136211?v=4", 17 | "profile": "https://fosstodon.org/@ehmicky", 18 | "contributions": [ 19 | "code", 20 | "design", 21 | "ideas", 22 | "doc" 23 | ] 24 | }, 25 | { 26 | "login": "papb", 27 | "name": "Pedro Augusto de Paula Barbosa", 28 | "avatar_url": "https://avatars.githubusercontent.com/u/20914054?v=4", 29 | "profile": "https://github.com/papb", 30 | "contributions": [ 31 | "doc" 32 | ] 33 | } 34 | ], 35 | "contributorsPerLine": 7, 36 | "skipCi": true, 37 | "commitConvention": "none" 38 | } 39 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | max_line_length = 80 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug 3 | title: Please replace with a clear and descriptive title 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for reporting this bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this bug has not already 15 | been reported. 16 | required: true 17 | - label: 18 | If this is related to a typo or the documentation being unclear, 19 | please click on the relevant page's `Edit` button (pencil icon) and 20 | suggest a correction instead. 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Describe the bug 25 | placeholder: A clear and concise description of what the bug is. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Steps to reproduce 31 | placeholder: | 32 | Step-by-step instructions on how to reproduce the behavior. 33 | Example: 34 | 1. Type the following command: [...] 35 | 2. etc. 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: Configuration 41 | placeholder: Command line options and/or configuration file, if any. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Environment 47 | description: | 48 | Enter the following command in a terminal and copy/paste its output: 49 | ```bash 50 | npx envinfo --system --binaries --browsers --npmPackages safe-json-value 51 | ``` 52 | validations: 53 | required: true 54 | - type: checkboxes 55 | attributes: 56 | label: Pull request (optional) 57 | description: 58 | Pull requests are welcome! If you would like to help us fix this bug, 59 | please check our [contributions 60 | guidelines](../blob/main/CONTRIBUTING.md). 61 | options: 62 | - label: I can submit a pull request. 63 | required: false 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_types.yml: -------------------------------------------------------------------------------- 1 | name: Bug report (TypeScript types) 2 | description: Report a bug about TypeScript types 3 | title: Please replace with a clear and descriptive title 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for reporting this bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this bug has not already 15 | been reported. 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the bug 20 | placeholder: A clear and concise description of what the bug is. 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Steps to reproduce 26 | description: | 27 | Please reproduce the bug using the [TypeScript playground](https://www.typescriptlang.org/play) or [Bug workbench](https://www.typescriptlang.org/dev/bug-workbench), then paste the URL here. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Environment 33 | description: | 34 | Enter the following command in a terminal and copy/paste its output: 35 | ```bash 36 | npx envinfo --system --binaries --browsers --npmPackages safe-json-value,typescript --npmGlobalPackages typescript 37 | ``` 38 | validations: 39 | required: true 40 | - type: checkboxes 41 | attributes: 42 | label: Pull request (optional) 43 | description: 44 | Pull requests are welcome! If you would like to help us fix this bug, 45 | please check our [contributions 46 | guidelines](../blob/main/CONTRIBUTING.md). 47 | options: 48 | - label: I can submit a pull request. 49 | required: false 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: Please replace with a clear and descriptive title 4 | labels: [enhancement] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for suggesting a new feature! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this feature has not already 15 | been requested. 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Which problem is this feature request solving? 20 | placeholder: I'm always frustrated when [...] 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Describe the solution you'd like 26 | placeholder: This could be fixed by [...] 27 | validations: 28 | required: true 29 | - type: checkboxes 30 | attributes: 31 | label: Pull request (optional) 32 | description: 33 | Pull requests are welcome! If you would like to help us fix this bug, 34 | please check our [contributions 35 | guidelines](../blob/main/CONTRIBUTING.md). 36 | options: 37 | - label: I can submit a pull request. 38 | required: false 39 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 🎉 Thanks for sending this pull request! 🎉 2 | 3 | Please make sure the title is clear and descriptive. 4 | 5 | If you are fixing a typo or documentation, please skip these instructions. 6 | 7 | Otherwise please fill in the sections below. 8 | 9 | **Which problem is this pull request solving?** 10 | 11 | Example: I'm always frustrated when [...] 12 | 13 | **List other issues or pull requests related to this problem** 14 | 15 | Example: This fixes #5012 16 | 17 | **Describe the solution you've chosen** 18 | 19 | Example: I've fixed this by [...] 20 | 21 | **Describe alternatives you've considered** 22 | 23 | Example: Another solution would be [...] 24 | 25 | **Checklist** 26 | 27 | Please add a `x` inside each checkbox: 28 | 29 | - [ ] I have read the [contribution guidelines](../blob/main/CONTRIBUTING.md). 30 | - [ ] I have added tests (we are enforcing 100% test coverage). 31 | - [ ] I have added documentation in the `README.md`, the `docs` directory (if 32 | any) 33 | - [ ] The status checks are successful (continuous integration). Those can be 34 | seen below. 35 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | jobs: 4 | combinations: 5 | uses: ehmicky/dev-tasks/.github/workflows/build.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | npm-debug.log 4 | node_modules 5 | /core 6 | .eslintcache 7 | .lycheecache 8 | .npmrc 9 | .yarn-error.log 10 | !.github/ 11 | /coverage 12 | /build 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.0.1 2 | 3 | ## Documentation 4 | 5 | - Improve documentation in `README.md` 6 | 7 | # 3.0.0 8 | 9 | ## Breaking changes 10 | 11 | - Minimal supported Node.js version is now `18.18.0` 12 | 13 | # 2.0.1 14 | 15 | ## Dependencies 16 | 17 | - Upgrade internal dependencies 18 | 19 | # 2.0.0 20 | 21 | ## Breaking changes 22 | 23 | - Minimal supported Node.js version is now `16.17.0` 24 | 25 | # 1.11.0 26 | 27 | ## Features 28 | 29 | - Improve TypeScript types 30 | 31 | # 1.10.0 32 | 33 | ## Features 34 | 35 | - Improve tree-shaking support 36 | 37 | # 1.9.0 38 | 39 | ## Features 40 | 41 | - Add browser support 42 | 43 | # 1.8.0 44 | 45 | ## Features 46 | 47 | - Add [`shallow` option](README.md#shallow). The logic is still deep by default. 48 | 49 | # 1.7.0 50 | 51 | ## Features 52 | 53 | - Improve error handling 54 | 55 | # 1.6.0 56 | 57 | ## Features 58 | 59 | - Reduce npm package size 60 | 61 | # 1.5.0 62 | 63 | ## Documentation 64 | 65 | - Add logo 66 | 67 | # 1.4.2 68 | 69 | ## Bug fixes 70 | 71 | - Better handle of infinite recursions 72 | 73 | # 1.4.1 74 | 75 | ## Bug fixes 76 | 77 | - Fix description on mobile devices 78 | 79 | # 1.4.0 80 | 81 | ## Documentation 82 | 83 | - Update description 84 | 85 | # 1.3.0 86 | 87 | ## Features 88 | 89 | - Ensure `changes[*].error` are always `Error` instances 90 | 91 | # 1.2.1 92 | 93 | ## Documentation 94 | 95 | - Add npm keywords 96 | 97 | # 1.2.0 98 | 99 | ## Features 100 | 101 | - Change `maxSize` option default value to `1e7` 102 | 103 | ## Bug fixes 104 | 105 | - Prevent crashes when a property value or key is a very large string 106 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | This text is available in 4 | [many other languages](https://www.contributor-covenant.org/translations). 5 | 6 | ## Our Pledge 7 | 8 | We as members, contributors, and leaders pledge to make participation in our 9 | community a harassment-free experience for everyone, regardless of age, body 10 | size, visible or invisible disability, ethnicity, sex characteristics, gender 11 | identity and expression, level of experience, education, socio-economic status, 12 | nationality, personal appearance, race, religion, or sexual identity and 13 | orientation. 14 | 15 | We pledge to act and interact in ways that contribute to an open, welcoming, 16 | diverse, inclusive, and healthy community. 17 | 18 | ## Our Standards 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | - Demonstrating empathy and kindness toward other people 24 | - Being respectful of differing opinions, viewpoints, and experiences 25 | - Giving and gracefully accepting constructive feedback 26 | - Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | - Focusing on what is best not just for us as individuals, but for the overall 29 | community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | - The use of sexualized language or imagery, and sexual attention or advances of 34 | any kind 35 | - Trolling, insulting or derogatory comments, and personal or political attacks 36 | - Public or private harassment 37 | - Publishing others' private information, such as a physical or email address, 38 | without their explicit permission 39 | - Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | ## Enforcement Responsibilities 43 | 44 | Community leaders are responsible for clarifying and enforcing our standards of 45 | acceptable behavior and will take appropriate and fair corrective action in 46 | response to any behavior that they deem inappropriate, threatening, offensive, 47 | or harmful. 48 | 49 | Community leaders have the right and responsibility to remove, edit, or reject 50 | comments, commits, code, wiki edits, issues, and other contributions that are 51 | not aligned to this Code of Conduct, and will communicate reasons for moderation 52 | decisions when appropriate. 53 | 54 | ## Scope 55 | 56 | This Code of Conduct applies within all community spaces, and also applies when 57 | an individual is officially representing the community in public spaces. 58 | Examples of representing our community include using an official e-mail address, 59 | posting via an official social media account, or acting as an appointed 60 | representative at an online or offline event. 61 | 62 | ## Enforcement 63 | 64 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 65 | reported to the community leaders responsible for enforcement at 66 | ehmicky+report@gmail.com All complaints will be reviewed and investigated 67 | promptly and fairly. 68 | 69 | All community leaders are obligated to respect the privacy and security of the 70 | reporter of any incident. 71 | 72 | ## Enforcement Guidelines 73 | 74 | Community leaders will follow these Community Impact Guidelines in determining 75 | the consequences for any action they deem in violation of this Code of Conduct: 76 | 77 | ### 1. Correction 78 | 79 | **Community Impact**: Use of inappropriate language or other behavior deemed 80 | unprofessional or unwelcome in the community. 81 | 82 | **Consequence**: A private, written warning from community leaders, providing 83 | clarity around the nature of the violation and an explanation of why the 84 | behavior was inappropriate. A public apology may be requested. 85 | 86 | ### 2. Warning 87 | 88 | **Community Impact**: A violation through a single incident or series of 89 | actions. 90 | 91 | **Consequence**: A warning with consequences for continued behavior. No 92 | interaction with the people involved, including unsolicited interaction with 93 | those enforcing the Code of Conduct, for a specified period of time. This 94 | includes avoiding interactions in community spaces as well as external channels 95 | like social media. Violating these terms may lead to a temporary or permanent 96 | ban. 97 | 98 | ### 3. Temporary Ban 99 | 100 | **Community Impact**: A serious violation of community standards, including 101 | sustained inappropriate behavior. 102 | 103 | **Consequence**: A temporary ban from any sort of interaction or public 104 | communication with the community for a specified period of time. No public or 105 | private interaction with the people involved, including unsolicited interaction 106 | with those enforcing the Code of Conduct, is allowed during this period. 107 | Violating these terms may lead to a permanent ban. 108 | 109 | ### 4. Permanent Ban 110 | 111 | **Community Impact**: Demonstrating a pattern of violation of community 112 | standards, including sustained inappropriate behavior, harassment of an 113 | individual, or aggression toward or disparagement of classes of individuals. 114 | 115 | **Consequence**: A permanent ban from any sort of public interaction within the 116 | community. 117 | 118 | ## Attribution 119 | 120 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 121 | version 2.0, available at 122 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 123 | 124 | Community Impact Guidelines were inspired by 125 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | 129 | For answers to common questions about this code of conduct, see the FAQ at 130 | https://www.contributor-covenant.org/faq. Translations are available at 131 | https://www.contributor-covenant.org/translations. 132 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | 🎉 Thanks for considering contributing to this project! 🎉 4 | 5 | These guidelines will help you send a pull request. 6 | 7 | If you're submitting an issue instead, please skip this document. 8 | 9 | If your pull request is related to a typo or the documentation being unclear, 10 | please click on the relevant page's `Edit` button (pencil icon) and directly 11 | suggest a correction instead. 12 | 13 | This project was made with ❤️. The simplest way to give back is by starring and 14 | sharing it online. 15 | 16 | Everyone is welcome regardless of personal background. We enforce a 17 | [Code of conduct](CODE_OF_CONDUCT.md) in order to promote a positive and 18 | inclusive environment. 19 | 20 | # Development process 21 | 22 | First fork and clone the repository. If you're not sure how to do this, please 23 | watch 24 | [these videos](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 25 | 26 | Run: 27 | 28 | ```bash 29 | npm install 30 | ``` 31 | 32 | Make sure everything is correctly setup with: 33 | 34 | ```bash 35 | npm test 36 | ``` 37 | 38 | We use Gulp tasks to lint, test and build this project. Please check 39 | [dev-tasks](https://github.com/ehmicky/dev-tasks/blob/main/README.md) to learn 40 | how to use them. You don't need to know Gulp to use these tasks. 41 | 42 | # Requirements 43 | 44 | Our coding style is documented 45 | [here](https://github.com/ehmicky/eslint-config#coding-style). Linting and 46 | formatting should automatically handle it though. 47 | 48 | After submitting the pull request, please make sure the Continuous Integration 49 | checks are passing. 50 | 51 | We enforce 100% test coverage: each line of code must be tested. 52 | 53 | New options, methods, properties, configuration and behavior must be documented 54 | in all of these: 55 | 56 | - the `README.md` 57 | - the `docs` directory (if any) 58 | 59 | Please use the same style as the rest of the documentation and examples. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 ehmicky 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | modern-errors logo 4 | 5 | 6 | [![Node](https://img.shields.io/badge/-Node.js-808080?logo=node.js&colorA=404040&logoColor=66cc33)](https://www.npmjs.com/package/safe-json-value) 7 | [![Browsers](https://img.shields.io/badge/-Browsers-808080?logo=firefox&colorA=404040)](https://unpkg.com/safe-json-value?module) 8 | [![TypeScript](https://img.shields.io/badge/-Typed-808080?logo=typescript&colorA=404040&logoColor=0096ff)](/src/main.d.ts) 9 | [![Codecov](https://img.shields.io/badge/-Tested%20100%25-808080?logo=codecov&colorA=404040)](https://codecov.io/gh/ehmicky/safe-json-value) 10 | [![Minified size](https://img.shields.io/bundlephobia/minzip/safe-json-value?label&colorA=404040&colorB=808080&logo=webpack)](https://bundlephobia.com/package/safe-json-value) 11 | [![Mastodon](https://img.shields.io/badge/-Mastodon-808080.svg?logo=mastodon&colorA=404040&logoColor=9590F9)](https://fosstodon.org/@ehmicky) 12 | [![Medium](https://img.shields.io/badge/-Medium-808080.svg?logo=medium&colorA=404040)](https://medium.com/@ehmicky) 13 | 14 | ⛑️ JSON serialization should never fail. 15 | 16 | # Features 17 | 18 | Prevent `JSON.stringify()` from: 19 | 20 | - [Throwing](#exceptions) 21 | - [Changing types](#unexpected-types) 22 | - [Filtering](#filtered-values) or [transforming values](#unresolved-values) 23 | unexpectedly 24 | 25 | # Example 26 | 27 | 28 | 29 | ```js 30 | import safeJsonValue from 'safe-json-value' 31 | 32 | const input = { one: true } 33 | input.self = input 34 | 35 | JSON.stringify(input) // Throws due to cycle 36 | const { value, changes } = safeJsonValue(input) 37 | JSON.stringify(value) // '{"one":true}" 38 | 39 | console.log(changes) // List of changed properties 40 | // [ 41 | // { 42 | // path: ['self'], 43 | // oldValue: { one: true, self: [Circular *1] }, 44 | // newValue: undefined, 45 | // reason: 'unsafeCycle' 46 | // } 47 | // ] 48 | ``` 49 | 50 | # Install 51 | 52 | ```bash 53 | npm install safe-json-value 54 | ``` 55 | 56 | This package works in both Node.js >=18.18.0 and 57 | [browsers](https://raw.githubusercontent.com/ehmicky/dev-tasks/main/src/browserslist). 58 | 59 | This is an ES module. It must be loaded using 60 | [an `import` or `import()` statement](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c), 61 | not `require()`. If TypeScript is used, it must be configured to 62 | [output ES modules](https://www.typescriptlang.org/docs/handbook/esm-node.html), 63 | not CommonJS. 64 | 65 | # API 66 | 67 | ## safeJsonValue(value, options?) 68 | 69 | `value` `any`\ 70 | `options` [`Options?`](#options)\ 71 | _Return value_: [`object`](#return-value) 72 | 73 | Makes `value` [JSON-safe](#changes-1) by: 74 | 75 | - Omitting properties which would [throw](#exceptions), 76 | [change type unexpectedly](#unexpected-types) or 77 | [be filtered](#filtered-values) with `JSON.stringify()` 78 | - Resolving properties which would [change value](#unresolved-values) with 79 | `JSON.stringify()` 80 | 81 | This never throws. 82 | 83 | ### Options 84 | 85 | Object with the following properties. 86 | 87 | #### maxSize 88 | 89 | _Type_: `number`\ 90 | _Default_: `1e7` 91 | 92 | Big JSON strings can make a process, filesystem operation or network request 93 | crash. `maxSize` prevents it by setting a maximum 94 | `JSON.stringify(value).length`. 95 | 96 | Additional properties beyond the size limit [are omitted](#big-output). They are 97 | completely removed, not truncated (including strings). 98 | 99 | ```js 100 | const input = { one: true, two: 'a'.repeat(1e6) } 101 | JSON.stringify(safeJsonValue(input, { maxSize: 1e5 }).value) // '{"one":true}" 102 | ``` 103 | 104 | #### shallow 105 | 106 | _Type_: `boolean`\ 107 | _Default_: `false` 108 | 109 | If `false`, object/array properties are processed recursively. Please note that 110 | [cycles](#cycles) are not removed when this is `true`. 111 | 112 | ### Return value 113 | 114 | Object with the following properties. 115 | 116 | #### value 117 | 118 | _Type_: `any` 119 | 120 | Copy of the input `value` after applying all the [changes](#changes-1) to make 121 | it JSON-safe. 122 | 123 | The top-level `value` itself might be changed (including to `undefined`) if it 124 | is either invalid JSON or has a [`toJSON()` method](#tojson). 125 | 126 | The `value` is not serialized to a JSON string. This allows choosing the 127 | serialization format (JSON, YAML, etc.), processing the value, etc. 128 | 129 | #### changes 130 | 131 | _Type_: `Change[]` 132 | 133 | List of [changes](#changes-1) applied to [`value`](#value). Each item is an 134 | individual change to a specific property. A given property might have multiple 135 | changes, listed in order. 136 | 137 | ##### changes[*].path 138 | 139 | _Type_: `Array` 140 | 141 | Property path. 142 | 143 | ##### changes[*].oldValue 144 | 145 | _Type_: `any` 146 | 147 | Property value before the change. 148 | 149 | ##### changes[*].newValue 150 | 151 | _Type_: `any` 152 | 153 | Property value after the change. `undefined` means the property was omitted. 154 | 155 | ##### changes[*].reason 156 | 157 | _Type_: `string` 158 | 159 | Reason for the change among: 160 | 161 | - [Exceptions](#exceptions): [`"unsafeCycle"`](#cycles), 162 | [`"unsafeBigInt"`](#bigint), [`"unsafeSize"`](#big-output), 163 | [`"unsafeException"`](#infinite-recursion), 164 | [`"unsafeToJSON"`](#exceptions-in-tojson), 165 | [`"unsafeGetter"`](#exceptions-in-getters) 166 | - [Invalid descriptors](#invalid-descriptors): 167 | [`"descriptorNotWritable"`](#non-writable-properties), 168 | [`"descriptorNotConfigurable"`](#non-configurable-properties) 169 | - [Unexpected types](#unexpected-types): 170 | [`"unstableInfinite"`](#nan-and-infinity) 171 | - [Filtered values](#filtered-values): [`"ignoredFunction"`](#functions), 172 | [`"ignoredUndefined"`](#undefined), [`"ignoredSymbolValue"`](#symbol-values), 173 | [`"ignoredSymbolKey"`](#symbol-keys), 174 | [`"ignoredNotEnumerable"`](#non-enumerable-keys), 175 | [`"ignoredArrayProperty"`](#array-properties) 176 | - [Unresolved values](#unresolved-values): [`"unresolvedToJSON"`](#tojson), 177 | [`"unresolvedClass"`](#classes), [`"unresolvedGetter"`](#getters) 178 | 179 | ##### changes[*].error 180 | 181 | _Type_: `Error?` 182 | 183 | Error that triggered the change. Only present if [`reason`](#changesreason) is 184 | [`"unsafeException"`](#infinite-recursion), 185 | [`"unsafeToJSON"`](#exceptions-in-tojson) or 186 | [`"unsafeGetter"`](#exceptions-in-getters). 187 | 188 | # Changes 189 | 190 | This is a list of all possible changes applied to make the value JSON-safe. 191 | 192 | ## Exceptions 193 | 194 | `JSON.stringify()` can throw on specific properties. Those are omitted. 195 | 196 | ### Cycles 197 | 198 | 199 | 200 | ```js 201 | const input = { one: true } 202 | input.self = input 203 | JSON.stringify(input) // Throws due to cycle 204 | JSON.stringify(safeJsonValue(input).value) // '{"one":true}" 205 | ``` 206 | 207 | ### Infinite recursion 208 | 209 | ```js 210 | const input = { toJSON: () => ({ one: true, input }) } 211 | JSON.stringify(input) // Throws due to infinite `toJSON()` recursion 212 | JSON.stringify(safeJsonValue(input).value) // '{"one":true,"input":{...}}" 213 | ``` 214 | 215 | ### BigInt 216 | 217 | ```js 218 | const input = { one: true, two: 0n } 219 | JSON.stringify(input) // Throws due to BigInt 220 | JSON.stringify(safeJsonValue(input).value) // '{"one":true}" 221 | ``` 222 | 223 | ### Big output 224 | 225 | ```js 226 | const input = { one: true, two: '\n'.repeat(5e8) } 227 | JSON.stringify(input) // Throws due to max string length 228 | JSON.stringify(safeJsonValue(input).value) // '{"one":true}" 229 | ``` 230 | 231 | ### Exceptions in `toJSON()` 232 | 233 | ```js 234 | const input = { 235 | one: true, 236 | two: { 237 | toJSON: () => { 238 | throw new Error('example') 239 | }, 240 | }, 241 | } 242 | JSON.stringify(input) // Throws due to `toJSON()` 243 | JSON.stringify(safeJsonValue(input).value) // '{"one":true}" 244 | ``` 245 | 246 | ### Exceptions in getters 247 | 248 | 249 | 250 | ```js 251 | const input = { 252 | one: true, 253 | get two() { 254 | throw new Error('example') 255 | }, 256 | } 257 | JSON.stringify(input) // Throws due to `get two()` 258 | JSON.stringify(safeJsonValue(input).value) // '{"one":true}" 259 | ``` 260 | 261 | ### Exceptions in proxies 262 | 263 | 264 | 265 | ```js 266 | const input = new Proxy( 267 | { one: false }, 268 | { 269 | get: () => { 270 | throw new Error('example') 271 | }, 272 | }, 273 | ) 274 | JSON.stringify(input) // Throws due to proxy 275 | JSON.stringify(safeJsonValue(input).value) // '{}' 276 | ``` 277 | 278 | ## Invalid descriptors 279 | 280 | ### Non-writable properties 281 | 282 | 283 | 284 | ```js 285 | const input = {} 286 | Object.defineProperty(input, 'one', { 287 | value: true, 288 | enumerable: true, 289 | writable: false, 290 | configurable: true, 291 | }) 292 | input.one = false // Throws: non-writable 293 | const safeInput = safeJsonValue(input).value 294 | safeInput.one = false // Does not throw: now writable 295 | ``` 296 | 297 | ### Non-configurable properties 298 | 299 | 300 | 301 | ```js 302 | const input = {} 303 | Object.defineProperty(input, 'one', { 304 | value: true, 305 | enumerable: true, 306 | writable: true, 307 | configurable: false, 308 | }) 309 | // Throws: non-configurable 310 | Object.defineProperty(input, 'one', { value: false, enumerable: false }) 311 | const safeInput = safeJsonValue(input).value 312 | // Does not throw: now configurable 313 | Object.defineProperty(safeInput, 'one', { value: false, enumerable: false }) 314 | ``` 315 | 316 | ## Unexpected types 317 | 318 | `JSON.stringify()` changes the types of specific values unexpectedly. Those are 319 | omitted. 320 | 321 | ### NaN and Infinity 322 | 323 | ```js 324 | const input = { one: true, two: Number.NaN, three: Number.POSITIVE_INFINITY } 325 | JSON.stringify(input) // '{"one":true,"two":null,"three":null}" 326 | JSON.stringify(safeJsonValue(input).value) // '{"one":true}" 327 | ``` 328 | 329 | ### Invalid array items 330 | 331 | 332 | 333 | ```js 334 | const input = [true, undefined, Symbol(), false] 335 | JSON.stringify(input) // '[true, null, null, false]' 336 | JSON.stringify(safeJsonValue(input).value) // '[true, false]' 337 | ``` 338 | 339 | ## Filtered values 340 | 341 | `JSON.stringify()` omits some specific types. Those are omitted right away to 342 | prevent any unexpected output. 343 | 344 | ### Functions 345 | 346 | 347 | 348 | ```js 349 | const input = { one: true, two: () => {} } 350 | JSON.parse(JSON.stringify(input)) // { one: true } 351 | safeJsonValue(input).value // { one: true } 352 | ``` 353 | 354 | ### `undefined` 355 | 356 | 357 | 358 | ```js 359 | const input = { one: true, two: undefined } 360 | JSON.parse(JSON.stringify(input)) // { one: true } 361 | safeJsonValue(input).value // { one: true } 362 | ``` 363 | 364 | ### Symbol values 365 | 366 | 367 | 368 | ```js 369 | const input = { one: true, two: Symbol() } 370 | JSON.parse(JSON.stringify(input)) // { one: true } 371 | safeJsonValue(input).value // { one: true } 372 | ``` 373 | 374 | ### Symbol keys 375 | 376 | 377 | 378 | ```js 379 | const input = { one: true, [Symbol()]: true } 380 | JSON.parse(JSON.stringify(input)) // { one: true } 381 | safeJsonValue(input).value // { one: true } 382 | ``` 383 | 384 | ### Non-enumerable keys 385 | 386 | 387 | 388 | ```js 389 | const input = { one: true } 390 | Object.defineProperty(input, 'two', { value: true, enumerable: false }) 391 | JSON.parse(JSON.stringify(input)) // { one: true } 392 | safeJsonValue(input).value // { one: true } 393 | ``` 394 | 395 | ### Array properties 396 | 397 | 398 | 399 | ```js 400 | const input = [true] 401 | input.prop = true 402 | JSON.parse(JSON.stringify(input)) // [true] 403 | safeJsonValue(input).value // [true] 404 | ``` 405 | 406 | ## Unresolved values 407 | 408 | `JSON.stringify()` can transform some values. Those are resolved right away to 409 | prevent any unexpected output. 410 | 411 | ### `toJSON()` 412 | 413 | 414 | 415 | ```js 416 | const input = { 417 | toJSON: () => ({ one: true }), 418 | } 419 | JSON.parse(JSON.stringify(input)) // { one: true } 420 | safeJsonValue(input).value // { one: true } 421 | ``` 422 | 423 | ### Dates 424 | 425 | 426 | 427 | ```js 428 | const input = { one: new Date() } 429 | JSON.parse(JSON.stringify(input)) // { one: '2022-07-29T14:37:40.865Z' } 430 | safeJsonValue(input).value // { one: '2022-07-29T14:37:40.865Z' } 431 | ``` 432 | 433 | ### Classes 434 | 435 | 436 | 437 | ```js 438 | const input = { one: new Set([]) } 439 | JSON.parse(JSON.stringify(input)) // { one: {} } 440 | safeJsonValue(input).value // { one: {} } 441 | ``` 442 | 443 | ### Getters 444 | 445 | 446 | 447 | ```js 448 | const input = { 449 | get one() { 450 | return true 451 | }, 452 | } 453 | JSON.parse(JSON.stringify(input)) // { one: true } 454 | safeJsonValue(input).value // { one: true } 455 | ``` 456 | 457 | ### Proxies 458 | 459 | 460 | 461 | ```js 462 | const input = new Proxy( 463 | { one: false }, 464 | { 465 | get: () => true, 466 | }, 467 | ) 468 | JSON.parse(JSON.stringify(input)) // { one: true } 469 | safeJsonValue(input).value // { one: true } 470 | ``` 471 | 472 | # Related projects 473 | 474 | - [`is-json-value`](https://github.com/ehmicky/is-json-value): Check if a value 475 | is valid JSON 476 | - [`truncate-json`](https://github.com/ehmicky/truncate-json): Truncate a JSON 477 | string 478 | - [`guess-json-indent`](https://github.com/ehmicky/guess-json-indent): Guess the 479 | indentation of a JSON string 480 | - [`error-serializer`](https://github.com/ehmicky/error-serializer): Convert 481 | errors to/from plain objects 482 | 483 | # Support 484 | 485 | For any question, _don't hesitate_ to [submit an issue on GitHub](../../issues). 486 | 487 | Everyone is welcome regardless of personal background. We enforce a 488 | [Code of conduct](CODE_OF_CONDUCT.md) in order to promote a positive and 489 | inclusive environment. 490 | 491 | # Contributing 492 | 493 | This project was made with ❤️. The simplest way to give back is by starring and 494 | sharing it online. 495 | 496 | If the documentation is unclear or has a typo, please click on the page's `Edit` 497 | button (pencil icon) and suggest a correction. 498 | 499 | If you would like to help us fix a bug or add a new feature, please check our 500 | [guidelines](CONTRIBUTING.md). Pull requests are welcome! 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 |

ehmicky

💻 🎨 🤔 📖

Pedro Augusto de Paula Barbosa

📖
515 | 516 | 517 | 518 | 519 | 520 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/dev-tasks/ava.config.js' 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintConfig from '@ehmicky/eslint-config' 2 | 3 | export default [ 4 | ...eslintConfig, 5 | { 6 | rules: { 7 | // This repository relies on JSON parsing/serializing 8 | 'unicorn/prefer-structured-clone': 0, 9 | }, 10 | }, 11 | ] 12 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | export * from '@ehmicky/dev-tasks' 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "safe-json-value", 3 | "version": "3.0.1", 4 | "type": "module", 5 | "exports": { 6 | "types": "./build/src/main.d.ts", 7 | "default": "./build/src/main.js" 8 | }, 9 | "main": "./build/src/main.js", 10 | "types": "./build/src/main.d.ts", 11 | "files": [ 12 | "build/src/**/*.{js,json,d.ts}", 13 | "!build/src/**/*.test.js", 14 | "!build/src/{helpers,fixtures}" 15 | ], 16 | "sideEffects": false, 17 | "scripts": { 18 | "test": "gulp test" 19 | }, 20 | "description": "⛑️ JSON serialization should never fail", 21 | "keywords": [ 22 | "bigint", 23 | "circular", 24 | "cycle", 25 | "enumerable", 26 | "exception-handling", 27 | "exceptions", 28 | "getters", 29 | "javascript", 30 | "json", 31 | "library", 32 | "nodejs", 33 | "parsing", 34 | "serialization", 35 | "symbols", 36 | "tojson", 37 | "types", 38 | "typescript", 39 | "valid", 40 | "validate", 41 | "validation" 42 | ], 43 | "license": "Apache-2.0", 44 | "homepage": "https://www.github.com/ehmicky/safe-json-value", 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/ehmicky/safe-json-value.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/ehmicky/safe-json-value/issues" 51 | }, 52 | "author": "ehmicky (https://github.com/ehmicky)", 53 | "directories": { 54 | "lib": "src" 55 | }, 56 | "dependencies": { 57 | "is-plain-obj": "^4.1.0", 58 | "normalize-exception": "^4.0.1" 59 | }, 60 | "devDependencies": { 61 | "@ehmicky/dev-tasks": "^3.0.34", 62 | "@ehmicky/eslint-config": "^20.0.32", 63 | "@ehmicky/prettier-config": "^1.0.6", 64 | "test-each": "^7.0.1" 65 | }, 66 | "engines": { 67 | "node": ">=18.18.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/prettier-config' 2 | -------------------------------------------------------------------------------- /src/array.js: -------------------------------------------------------------------------------- 1 | import { transformProp } from './prop.js' 2 | 3 | // Recurse over array items. 4 | // Only array indices are kept. 5 | // - Even if either not enumerable or if inherited, to mimic `JSON.stringify()` 6 | // behavior 7 | // Omitted items are filtered out. 8 | // - Otherwise, `JSON.stringify()` would transform them to `null` 9 | // Uses imperative logic for performance reasons. 10 | /* eslint-disable fp/no-let, fp/no-loops, fp/no-mutation, max-depth */ 11 | export const recurseArray = ({ 12 | array, 13 | changes, 14 | ancestors, 15 | path, 16 | size, 17 | maxSize, 18 | recurse, 19 | }) => { 20 | const newArray = [] 21 | 22 | let state = { empty: true, size } 23 | 24 | for (let index = 0; index < array.length; index += 1) { 25 | state = transformProp({ 26 | parent: array, 27 | changes, 28 | ancestors, 29 | path, 30 | maxSize, 31 | key: index, 32 | type: 'arrayItem', 33 | empty: state.empty, 34 | size: state.size, 35 | recurse, 36 | }) 37 | 38 | if (state.value !== undefined) { 39 | // eslint-disable-next-line fp/no-mutating-methods 40 | newArray.push(state.value) 41 | } 42 | } 43 | 44 | return { value: newArray, size: state.size } 45 | } 46 | /* eslint-enable fp/no-let, fp/no-loops, fp/no-mutation, max-depth */ 47 | -------------------------------------------------------------------------------- /src/array.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import safeJsonValue from 'safe-json-value' 4 | 5 | test('Omit removed properties', (t) => { 6 | t.deepEqual(safeJsonValue([0, undefined, 1]), { 7 | value: [0, 1], 8 | changes: [ 9 | { 10 | path: [1], 11 | oldValue: undefined, 12 | newValue: undefined, 13 | reason: 'ignoredUndefined', 14 | }, 15 | ], 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/cycle.js: -------------------------------------------------------------------------------- 1 | import { recurseArray } from './array.js' 2 | import { isObject } from './is_object.js' 3 | import { recurseObject } from './object.js' 4 | 5 | // We omit cycles since `JSON.stringify()` throws on them. 6 | export const checkCycleThenRecurse = ({ 7 | value, 8 | changes, 9 | ancestors, 10 | path, 11 | size, 12 | newSize, 13 | maxSize, 14 | recurse, 15 | }) => { 16 | if (!isObject(value)) { 17 | return { value, size: newSize } 18 | } 19 | 20 | if (ancestors.has(value)) { 21 | // eslint-disable-next-line fp/no-mutating-methods 22 | changes.push({ 23 | path, 24 | oldValue: value, 25 | newValue: undefined, 26 | reason: 'unsafeCycle', 27 | }) 28 | return { value: undefined, size } 29 | } 30 | 31 | ancestors.add(value) 32 | const { value: valueA, size: newSizeA } = recurseValue({ 33 | value, 34 | changes, 35 | ancestors, 36 | path, 37 | size: newSize, 38 | maxSize, 39 | recurse, 40 | }) 41 | ancestors.delete(value) 42 | return { value: valueA, size: newSizeA } 43 | } 44 | 45 | const recurseValue = ({ 46 | value, 47 | changes, 48 | ancestors, 49 | path, 50 | size, 51 | maxSize, 52 | recurse, 53 | }) => 54 | Array.isArray(value) 55 | ? recurseArray({ 56 | array: value, 57 | changes, 58 | ancestors, 59 | path, 60 | size, 61 | maxSize, 62 | recurse, 63 | }) 64 | : recurseObject({ 65 | object: value, 66 | changes, 67 | ancestors, 68 | path, 69 | size, 70 | maxSize, 71 | recurse, 72 | }) 73 | -------------------------------------------------------------------------------- /src/cycle.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import safeJsonValue from 'safe-json-value' 4 | 5 | test('Omit circular values', (t) => { 6 | const input = {} 7 | // eslint-disable-next-line fp/no-mutation 8 | input.self = input 9 | const { value, changes } = safeJsonValue(input) 10 | t.false('self' in value) 11 | t.deepEqual(changes, [ 12 | { 13 | path: ['self'], 14 | oldValue: input, 15 | newValue: undefined, 16 | reason: 'unsafeCycle', 17 | }, 18 | ]) 19 | }) 20 | -------------------------------------------------------------------------------- /src/get.js: -------------------------------------------------------------------------------- 1 | import normalizeException from 'normalize-exception' 2 | 3 | // Same as `safeGetProp()` but without any `changes` 4 | export const safeGetChangeProp = ({ parent, key }) => { 5 | try { 6 | return parent[key] 7 | } catch {} 8 | } 9 | 10 | // `parent[key]` might be a getter or proxy hook. This resolves it. 11 | // If it throws, the property is omitted. 12 | // It is not possible to detect that a proxy is being used, except when it 13 | // throws, so we cannot add this to the `changes`. 14 | export const safeGetProp = ({ parent, key, changes, path }) => { 15 | try { 16 | const prop = getProp({ parent, key, changes, path }) 17 | return { prop, safe: true } 18 | } catch (error) { 19 | // eslint-disable-next-line fp/no-mutating-methods 20 | changes.push({ 21 | path, 22 | oldValue: undefined, 23 | newValue: undefined, 24 | reason: 'unsafeGetter', 25 | error: normalizeException(error), 26 | }) 27 | return { prop: undefined, safe: false } 28 | } 29 | } 30 | 31 | // The descriptor is retrieved first in case there is a getter or proxy hook 32 | // that modifies `parent[key]` 33 | const getProp = ({ parent, key, changes, path }) => { 34 | const descriptor = Object.getOwnPropertyDescriptor(parent, key) 35 | const prop = parent[key] 36 | addGetterChange({ changes, path, prop, descriptor }) 37 | addDescriptorChange({ changes, path, prop, descriptor }) 38 | return prop 39 | } 40 | 41 | // When `parent[key]` was a getter and|or setter 42 | const addGetterChange = ({ changes, path, prop, descriptor: { get, set } }) => { 43 | if (get !== undefined || set !== undefined) { 44 | // eslint-disable-next-line fp/no-mutating-methods 45 | changes.push({ 46 | path, 47 | oldValue: get, 48 | newValue: prop, 49 | reason: 'unresolvedGetter', 50 | }) 51 | } 52 | } 53 | 54 | // We convert non-writable|configurable properties to writable|configurable 55 | const addDescriptorChange = ({ 56 | changes, 57 | path, 58 | prop, 59 | descriptor: { writable, configurable }, 60 | }) => { 61 | if (writable === false) { 62 | // eslint-disable-next-line fp/no-mutating-methods 63 | changes.push({ 64 | path, 65 | oldValue: prop, 66 | newValue: prop, 67 | reason: 'descriptorNotWritable', 68 | }) 69 | } 70 | 71 | if (configurable === false) { 72 | // eslint-disable-next-line fp/no-mutating-methods 73 | changes.push({ 74 | path, 75 | oldValue: prop, 76 | newValue: prop, 77 | reason: 'descriptorNotConfigurable', 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/get.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | each( 7 | [ 8 | { 9 | descriptor: { configurable: false, writable: true }, 10 | reason: 'descriptorNotConfigurable', 11 | }, 12 | { 13 | descriptor: { configurable: true, writable: false }, 14 | reason: 'descriptorNotWritable', 15 | }, 16 | ], 17 | ({ title }, { descriptor, reason }) => { 18 | test(`Make properties configurable and writable | ${title}`, (t) => { 19 | // eslint-disable-next-line fp/no-mutating-methods 20 | const input = Object.defineProperty({}, 'prop', { 21 | value: true, 22 | enumerable: true, 23 | ...descriptor, 24 | }) 25 | const { value, changes } = safeJsonValue(input) 26 | t.deepEqual(value, { prop: true }) 27 | t.deepEqual(Object.getOwnPropertyDescriptor(value, 'prop'), { 28 | value: true, 29 | enumerable: true, 30 | configurable: true, 31 | writable: true, 32 | }) 33 | t.deepEqual(changes, [ 34 | { path: ['prop'], oldValue: true, newValue: true, reason }, 35 | ]) 36 | }) 37 | }, 38 | ) 39 | 40 | each( 41 | [ 42 | { 43 | input: { 44 | // eslint-disable-next-line fp/no-get-set 45 | get prop() { 46 | return true 47 | }, 48 | }, 49 | }, 50 | { 51 | input: { 52 | // eslint-disable-next-line fp/no-get-set 53 | get prop() { 54 | return true 55 | }, 56 | // eslint-disable-next-line fp/no-get-set 57 | set prop(_) {}, 58 | }, 59 | }, 60 | { 61 | input: { 62 | // eslint-disable-next-line fp/no-get-set 63 | get prop() { 64 | // eslint-disable-next-line fp/no-mutating-methods, fp/no-this 65 | Object.defineProperty(this, 'prop', { 66 | value: true, 67 | enumerable: true, 68 | writable: true, 69 | configurable: true, 70 | }) 71 | return true 72 | }, 73 | }, 74 | title: 'selfModifyingProp', 75 | }, 76 | ], 77 | ({ title }, { input }) => { 78 | test(`Resolve getters | ${title}`, (t) => { 79 | const { get } = Object.getOwnPropertyDescriptor(input, 'prop') 80 | t.deepEqual(safeJsonValue(input), { 81 | value: { prop: true }, 82 | changes: [ 83 | { 84 | path: ['prop'], 85 | oldValue: get, 86 | newValue: true, 87 | reason: 'unresolvedGetter', 88 | }, 89 | ], 90 | }) 91 | }) 92 | }, 93 | ) 94 | 95 | test('Resolve setters without getters', (t) => { 96 | // eslint-disable-next-line fp/no-get-set, accessor-pairs 97 | const input = { set prop(_) {} } 98 | const change = { path: ['prop'], newValue: undefined, oldValue: undefined } 99 | t.deepEqual(safeJsonValue(input), { 100 | value: {}, 101 | changes: [ 102 | { ...change, reason: 'unresolvedGetter' }, 103 | { ...change, reason: 'ignoredUndefined' }, 104 | ], 105 | }) 106 | }) 107 | 108 | test('Omit getters that throw', (t) => { 109 | const error = new Error('test') 110 | // eslint-disable-next-line fp/no-mutating-methods 111 | const input = Object.defineProperty({}, 'prop', { 112 | get: () => { 113 | throw error.message 114 | }, 115 | enumerable: true, 116 | configurable: true, 117 | }) 118 | t.deepEqual(safeJsonValue(input), { 119 | value: {}, 120 | changes: [ 121 | { 122 | path: ['prop'], 123 | oldValue: undefined, 124 | newValue: undefined, 125 | reason: 'unsafeGetter', 126 | error, 127 | }, 128 | ], 129 | }) 130 | }) 131 | 132 | test('Resolve proxy get hooks', (t) => { 133 | // eslint-disable-next-line fp/no-proxy 134 | const input = new Proxy( 135 | { prop: false }, 136 | { 137 | get: (...args) => { 138 | // Ensures the `value` returned by `safeJsonValue` is not a Proxy 139 | // anymore 140 | if (Reflect.get(...args)) { 141 | throw new Error('test') 142 | } 143 | 144 | return true 145 | }, 146 | }, 147 | ) 148 | t.deepEqual(safeJsonValue(input), { 149 | value: { prop: true }, 150 | changes: [], 151 | }) 152 | }) 153 | 154 | test('Omit proxy get hooks that throw', (t) => { 155 | const error = new Error('test') 156 | // eslint-disable-next-line fp/no-proxy 157 | const input = new Proxy( 158 | { prop: true }, 159 | { 160 | get: () => { 161 | throw error.message 162 | }, 163 | }, 164 | ) 165 | t.deepEqual(safeJsonValue(input), { 166 | value: {}, 167 | changes: [ 168 | { 169 | path: ['prop'], 170 | oldValue: undefined, 171 | newValue: undefined, 172 | error, 173 | reason: 'unsafeGetter', 174 | }, 175 | ], 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /src/indices.js: -------------------------------------------------------------------------------- 1 | import { safeGetChangeProp } from './get.js' 2 | 3 | // Omit array properties that are not indices. 4 | // - This mimics `JSON.stringify()` behavior 5 | // - Regardless of whether they are symbols and|or non-enumerable 6 | // Uses imperative logic for performance reasons. 7 | /* eslint-disable fp/no-loops, max-depth */ 8 | export const addNotArrayIndexChanges = (array, changes, path) => { 9 | if (!Array.isArray(array)) { 10 | return 11 | } 12 | 13 | const arrayProps = getArrayProps(array.length) 14 | 15 | for (const key of Reflect.ownKeys(array)) { 16 | if (!arrayProps.has(key)) { 17 | // eslint-disable-next-line fp/no-mutating-methods 18 | changes.push({ 19 | path: [...path, key], 20 | oldValue: safeGetChangeProp({ parent: array, key }), 21 | newValue: undefined, 22 | reason: 'ignoredArrayProperty', 23 | }) 24 | } 25 | } 26 | } 27 | /* eslint-enable fp/no-loops, max-depth */ 28 | 29 | // `Array.length` is omitted by `JSON.stringify()`. But since every array has 30 | // this property, we do not add it to `changes`. 31 | const getArrayProps = (length) => { 32 | const indices = Array.from({ length }, getArrayIndex) 33 | const arrayProps = new Set(indices) 34 | arrayProps.add('length') 35 | return arrayProps 36 | } 37 | 38 | const getArrayIndex = (_, index) => String(index) 39 | -------------------------------------------------------------------------------- /src/indices.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | each( 7 | ['prop', Symbol('test')], 8 | [true, false], 9 | [ 10 | { descriptor: { value: true, writable: true }, oldValue: true }, 11 | { 12 | descriptor: { 13 | get: () => { 14 | throw new Error('test') 15 | }, 16 | }, 17 | oldValue: undefined, 18 | }, 19 | ], 20 | // eslint-disable-next-line max-params 21 | ({ title }, key, enumerable, { descriptor, oldValue }) => { 22 | test(`Omit array properties that are not indices | ${title}`, (t) => { 23 | // eslint-disable-next-line fp/no-mutating-methods 24 | const array = Object.defineProperty([true], key, { 25 | ...descriptor, 26 | enumerable, 27 | configurable: true, 28 | }) 29 | const { value, changes } = safeJsonValue(array) 30 | t.true(value[0]) 31 | t.false(key in value) 32 | t.deepEqual(changes, [ 33 | { 34 | path: [key], 35 | oldValue, 36 | newValue: undefined, 37 | reason: 'ignoredArrayProperty', 38 | }, 39 | ]) 40 | }) 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /src/is_object.js: -------------------------------------------------------------------------------- 1 | // Check if a value is a an object 2 | export const isObject = (value) => typeof value === 'object' && value !== null 3 | -------------------------------------------------------------------------------- /src/key.js: -------------------------------------------------------------------------------- 1 | // Omit properties which are ignored by `JSON.stringify()`: 2 | // - Symbol keys 3 | // - Non-enumerable properties, except in arrays 4 | export const omitInvalidKey = ({ parent, key, prop, changes, path }) => { 5 | if (typeof key === 'symbol') { 6 | // eslint-disable-next-line fp/no-mutating-methods 7 | changes.push({ 8 | path, 9 | oldValue: prop, 10 | newValue: undefined, 11 | reason: 'ignoredSymbolKey', 12 | }) 13 | return { prop: undefined, validKey: false } 14 | } 15 | 16 | if (!isEnum.call(parent, key) && !Array.isArray(parent)) { 17 | // eslint-disable-next-line fp/no-mutating-methods 18 | changes.push({ 19 | path, 20 | oldValue: prop, 21 | newValue: undefined, 22 | reason: 'ignoredNotEnumerable', 23 | }) 24 | return { prop: undefined, validKey: false } 25 | } 26 | 27 | return { prop, validKey: true } 28 | } 29 | 30 | const { propertyIsEnumerable: isEnum } = Object.prototype 31 | -------------------------------------------------------------------------------- /src/key.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | const symbol = Symbol('test') 7 | each( 8 | [ 9 | { 10 | input: { [symbol]: true }, 11 | output: {}, 12 | changes: [ 13 | { 14 | path: [symbol], 15 | oldValue: true, 16 | newValue: undefined, 17 | reason: 'ignoredSymbolKey', 18 | }, 19 | ], 20 | }, 21 | { 22 | // eslint-disable-next-line fp/no-mutating-methods 23 | input: Object.defineProperty({}, 'prop', { 24 | value: true, 25 | enumerable: false, 26 | writable: true, 27 | configurable: true, 28 | }), 29 | output: {}, 30 | changes: [ 31 | { 32 | path: ['prop'], 33 | oldValue: true, 34 | newValue: undefined, 35 | reason: 'ignoredNotEnumerable', 36 | }, 37 | ], 38 | }, 39 | { 40 | // eslint-disable-next-line fp/no-mutating-methods 41 | input: Object.defineProperty([], '0', { 42 | value: true, 43 | enumerable: false, 44 | writable: true, 45 | configurable: true, 46 | }), 47 | output: [true], 48 | changes: [], 49 | }, 50 | ], 51 | ({ title }, { input, output, changes }) => { 52 | test(`Omit invalid keys | ${title}`, (t) => { 53 | t.deepEqual(safeJsonValue(input), { value: output, changes }) 54 | }) 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /src/main.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * `safe-json-value` options 3 | */ 4 | export interface Options { 5 | /** 6 | * Big JSON strings can make a process, filesystem operation or network 7 | * request crash. 8 | * `maxSize` prevents it by setting a maximum `JSON.stringify(value).length`. 9 | * 10 | * Additional properties beyond the size limit are omitted. 11 | * They are completely removed, not truncated (including strings). 12 | * 13 | * @default 1e7 14 | * 15 | * @example 16 | * ```js 17 | * const input = { one: true, two: 'a'.repeat(1e6) } 18 | * JSON.stringify(safeJsonValue(input, { maxSize: 1e5 }).value) // '{"one":true}" 19 | * ``` 20 | */ 21 | readonly maxSize?: number 22 | 23 | /** 24 | * If `false`, object/array properties are processed recursively. 25 | * Please note that cycles are not removed when this is `true`. 26 | * 27 | * @default false 28 | */ 29 | readonly shallow?: boolean 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 33 | type InvalidJSONValue = bigint | Function | undefined | symbol 34 | 35 | type ReturnValue = T extends (infer ArrayItem)[] 36 | ? Shallow extends true 37 | ? ArrayItem[] 38 | : ReturnValue[] 39 | : T extends InvalidJSONValue 40 | ? undefined 41 | : T extends Date 42 | ? string 43 | : T extends { toJSON: () => unknown } 44 | ? ReturnType 45 | : T extends object 46 | ? { 47 | [key in keyof T as T[key] extends InvalidJSONValue 48 | ? never 49 | : Exclude]?: Shallow extends true 50 | ? T[key] 51 | : ReturnValue 52 | } 53 | : T 54 | 55 | type ReasonWithError = 'unsafeException' | 'unsafeGetter' | 'unsafeToJSON' 56 | 57 | type ReasonWithoutError = 58 | | 'descriptorNotConfigurable' 59 | | 'descriptorNotWritable' 60 | | 'ignoredArrayProperty' 61 | | 'ignoredFunction' 62 | | 'ignoredNotEnumerable' 63 | | 'ignoredSymbolKey' 64 | | 'ignoredSymbolValue' 65 | | 'ignoredUndefined' 66 | | 'unresolvedClass' 67 | | 'unresolvedGetter' 68 | | 'unresolvedToJSON' 69 | | 'unsafeBigInt' 70 | | 'unsafeCycle' 71 | | 'unsafeSize' 72 | | 'unstableInfinite' 73 | 74 | /** 75 | * Reason why a property was changed. 76 | */ 77 | export type Reason = ReasonWithError | ReasonWithoutError 78 | 79 | /** 80 | * Change applied to [`value`](#value). 81 | * Each item is an individual change to a specific property. 82 | * A given property might have multiple changes, listed in order. 83 | */ 84 | export type Change = { 85 | /** 86 | * Property path. 87 | */ 88 | path: PropertyKey[] 89 | 90 | /** 91 | * Property value before the change. 92 | */ 93 | oldValue: unknown 94 | 95 | /** 96 | * Property value after the change. 97 | * `undefined` means the property was omitted. 98 | */ 99 | newValue: unknown 100 | 101 | /** 102 | * Reason for the change. 103 | */ 104 | reason: ReasonValue 105 | } & (ReasonValue extends ReasonWithError 106 | ? { 107 | /** 108 | * Error that triggered the change. 109 | */ 110 | error: Error 111 | } 112 | : object) 113 | 114 | /** 115 | * Makes `value` JSON-safe by: 116 | * - Omitting properties which would throw, change type unexpectedly or be 117 | * filtered with `JSON.stringify()` 118 | * - Resolving properties which would change value with `JSON.stringify()` 119 | * 120 | * Applied recursively on object/array properties. This never throws. 121 | * 122 | * @example 123 | * ```js 124 | * const input = { one: true } 125 | * input.self = input 126 | * 127 | * JSON.stringify(input) // Throws due to cycle 128 | * const { value, changes } = safeJsonValue(input) 129 | * JSON.stringify(value) // '{"one":true}" 130 | * 131 | * console.log(changes) // List of changed properties 132 | * // [ 133 | * // { 134 | * // path: ['self'], 135 | * // oldValue: { one: true, self: [Circular *1] }, 136 | * // newValue: undefined, 137 | * // reason: 'unsafeCycle' 138 | * // } 139 | * // ] 140 | * ``` 141 | */ 142 | export default function safeJsonValue( 143 | value: T, 144 | options?: OptionsArg, 145 | ): { 146 | /** 147 | * Copy of the input `value` after applying all the changes to make 148 | * it JSON-safe. 149 | * 150 | * The top-level `value` itself might be changed (including to `undefined`) if 151 | * it is either invalid JSON or has a `toJSON()` method. 152 | */ 153 | value: 154 | | ReturnValue 155 | | undefined 156 | 157 | /** 158 | * List of changes applied to `value`. 159 | */ 160 | changes: Change[] 161 | } 162 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { checkCycleThenRecurse } from './cycle.js' 2 | import { addNotArrayIndexChanges } from './indices.js' 3 | import { addSize, DEFAULT_MAX_SIZE } from './size.js' 4 | import { callToJSON } from './to_json.js' 5 | import { omitInvalidTypes } from './type.js' 6 | import { handleUnsafeException } from './uncaught.js' 7 | 8 | // Non-goals of this library: 9 | // - Keeping or transtyping incompatible values 10 | // - We only omit them, since this is simpler to handle for consumer 11 | // - I.e. the result is lossy 12 | // - Canonicalizing the value 13 | // - Supporting other formats than JSON 14 | const safeJsonValue = ( 15 | value, 16 | { maxSize = DEFAULT_MAX_SIZE, shallow = false } = {}, 17 | ) => { 18 | const changes = [] 19 | const ancestors = new Set([]) 20 | const { value: newValue } = transformValue({ 21 | value, 22 | changes, 23 | ancestors, 24 | path: [], 25 | size: 0, 26 | maxSize, 27 | shallow, 28 | }) 29 | return { value: newValue, changes } 30 | } 31 | 32 | export default safeJsonValue 33 | 34 | // The final top-level return value: 35 | // - Might be `undefined` 36 | // - Is not serialized to a string 37 | const transformValue = ({ 38 | value, 39 | changes, 40 | ancestors, 41 | path, 42 | size, 43 | maxSize, 44 | shallow, 45 | }) => { 46 | try { 47 | const valueA = callToJSON(value, changes, path) 48 | const valueB = omitInvalidTypes(valueA, changes, path) 49 | addNotArrayIndexChanges(valueB, changes, path) 50 | return checkSizeThenRecurse({ 51 | value: valueB, 52 | changes, 53 | ancestors, 54 | path, 55 | size, 56 | maxSize, 57 | shallow, 58 | }) 59 | } catch (error) { 60 | return handleUnsafeException({ value, changes, path, error, size }) 61 | } 62 | } 63 | 64 | // Recurse over plain objects and arrays. 65 | // We use a depth-first traversal. 66 | // - I.e. parent, then children, then siblings 67 | // - This works better with `maxSize` 68 | // - This allows stopping logic when `maxSize` is reached, resulting in 69 | // better performance 70 | // - This favors removing fewer big fields instead of more small fields, 71 | // resulting in fewer `changes` 72 | // - This favors maximizing the number of fields within the allowed 73 | // `maxSize` 74 | // - This is easier to implement 75 | const checkSizeThenRecurse = ({ 76 | value, 77 | changes, 78 | ancestors, 79 | path, 80 | size, 81 | maxSize, 82 | shallow, 83 | }) => { 84 | const { size: newSize, stop } = addSize({ 85 | type: 'value', 86 | size, 87 | maxSize, 88 | changes, 89 | path, 90 | context: value, 91 | }) 92 | 93 | if (stop) { 94 | return { value: undefined, size } 95 | } 96 | 97 | const recurse = shallow ? identity : transformValue 98 | return checkCycleThenRecurse({ 99 | value, 100 | changes, 101 | ancestors, 102 | path, 103 | size, 104 | newSize, 105 | maxSize, 106 | recurse, 107 | }) 108 | } 109 | 110 | const identity = ({ value, changes }) => ({ value, changes }) 111 | -------------------------------------------------------------------------------- /src/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectNotAssignable, expectType } from 'tsd' 2 | 3 | import safeJsonValue, { 4 | type Change, 5 | type Options, 6 | type Reason, 7 | } from 'safe-json-value' 8 | 9 | const trueValue = true as const 10 | const arrayValue = [0 as const, trueValue] 11 | expectType(safeJsonValue(trueValue).value) 12 | expectType(safeJsonValue([]).value) 13 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 14 | expectType<{} | undefined>(safeJsonValue({}).value) 15 | expectType<(0 | true)[] | undefined>(safeJsonValue(arrayValue).value) 16 | expectType<{ a?: true } | undefined>(safeJsonValue({ a: trueValue }).value) 17 | expectType<{ a?: true }[] | undefined>(safeJsonValue([{ a: trueValue }]).value) 18 | expectType<{ a?: (0 | true)[] } | undefined>( 19 | safeJsonValue({ a: arrayValue }).value, 20 | ) 21 | const arrayWithProps: boolean[] & { prop?: boolean } = [true] 22 | // eslint-disable-next-line fp/no-mutation 23 | arrayWithProps.prop = true 24 | expectType(safeJsonValue(arrayWithProps).value) 25 | expectType<{ a?: boolean[] } | undefined>( 26 | safeJsonValue({ a: arrayWithProps }).value, 27 | ) 28 | expectType(safeJsonValue(new Date()).value) 29 | expectType<{ a?: string } | undefined>(safeJsonValue({ a: new Date() }).value) 30 | const objWithToJSON = { 31 | toJSON: (): true => true, 32 | } 33 | expectType(safeJsonValue(objWithToJSON).value) 34 | expectType<{ a?: true } | undefined>(safeJsonValue({ a: objWithToJSON }).value) 35 | expectType<{ a?: true } | undefined>( 36 | safeJsonValue({ a: trueValue, [Symbol('test')]: trueValue }).value, 37 | ) 38 | expectType(safeJsonValue(undefined).value) 39 | expectType<{ a?: true } | undefined>( 40 | safeJsonValue({ a: trueValue, b: undefined }).value, 41 | ) 42 | expectType(safeJsonValue(0n).value) 43 | expectType<{ a?: true } | undefined>( 44 | safeJsonValue({ a: trueValue, b: 0n }).value, 45 | ) 46 | expectType(safeJsonValue(Symbol('test')).value) 47 | expectType<{ a?: true } | undefined>( 48 | safeJsonValue({ a: trueValue, b: Symbol('test') }).value, 49 | ) 50 | expectType(safeJsonValue(() => {}).value) 51 | expectType<{ a?: true } | undefined>( 52 | safeJsonValue({ a: trueValue, b: () => {} }).value, 53 | ) 54 | 55 | expectType(safeJsonValue(undefined).changes) 56 | const change = { 57 | path: ['' as const, 0 as const, Symbol('test')], 58 | oldValue: undefined, 59 | newValue: undefined, 60 | reason: 'unresolvedClass' as const, 61 | } 62 | expectAssignable(change) 63 | 64 | type UnsafeExceptionChange = Change<'unsafeException'> 65 | 66 | const changeWithError = { 67 | ...change, 68 | reason: 'unsafeException', 69 | error: new Error(''), 70 | } 71 | expectNotAssignable({ 72 | ...change, 73 | reason: 'unsafeException', 74 | }) 75 | expectNotAssignable({ 76 | ...changeWithError, 77 | reason: 'unresolvedClass', 78 | }) 79 | expectAssignable({ 80 | ...changeWithError, 81 | reason: 'unsafeException', 82 | }) 83 | 84 | expectNotAssignable({ ...change, path: '' }) 85 | expectNotAssignable({ ...change, path: [true] }) 86 | 87 | expectType((change as Change).oldValue) 88 | expectType((change as Change).newValue) 89 | expectType((changeWithError as UnsafeExceptionChange).error) 90 | 91 | expectType((change as Change).reason) 92 | expectAssignable('unresolvedClass') 93 | expectNotAssignable('unknown') 94 | expectNotAssignable(true) 95 | 96 | safeJsonValue('', {}) 97 | expectAssignable({}) 98 | // @ts-expect-error 99 | safeJsonValue('', true) 100 | expectNotAssignable(true) 101 | 102 | // @ts-expect-error 103 | safeJsonValue('', { unknown: true }) 104 | expectNotAssignable({ unknown: true }) 105 | 106 | safeJsonValue('', { maxSize: 0 }) 107 | expectAssignable({ maxSize: 0 }) 108 | safeJsonValue('', { maxSize: Number.POSITIVE_INFINITY }) 109 | expectAssignable({ maxSize: Number.POSITIVE_INFINITY }) 110 | // @ts-expect-error 111 | safeJsonValue('', { maxSize: '0' }) 112 | expectNotAssignable({ maxSize: '0' }) 113 | 114 | safeJsonValue('', { shallow: true }) 115 | expectAssignable({ shallow: true }) 116 | // @ts-expect-error 117 | safeJsonValue('', { shallow: 'true' }) 118 | expectNotAssignable({ shallow: 'true' }) 119 | 120 | expectType(safeJsonValue({ a: new Date() }).value?.a) 121 | expectType( 122 | safeJsonValue({ a: new Date() }, { shallow: true }).value?.a, 123 | ) 124 | expectType(safeJsonValue([new Date()]).value?.[0]) 125 | expectType( 126 | safeJsonValue([new Date()], { shallow: true }).value?.[0], 127 | ) 128 | -------------------------------------------------------------------------------- /src/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import safeJsonValue from 'safe-json-value' 4 | 5 | test('Is deep by default on objects', (t) => { 6 | t.deepEqual(safeJsonValue({ one: 0n }).value, {}) 7 | }) 8 | 9 | test('Is deep by default on arrays', (t) => { 10 | t.deepEqual(safeJsonValue([0n]).value, []) 11 | }) 12 | 13 | test('Can be shallow on objects', (t) => { 14 | const value = { one: 0n } 15 | // eslint-disable-next-line fp/no-mutating-methods 16 | Object.defineProperty(value, 'two', { 17 | value: true, 18 | enumerable: false, 19 | writable: true, 20 | configurable: true, 21 | }) 22 | t.deepEqual(safeJsonValue(value, { shallow: true }).value, { one: 0n }) 23 | }) 24 | 25 | test('Can be shallow on arrays', (t) => { 26 | const value = [0n] 27 | t.deepEqual(safeJsonValue(value, { shallow: true }).value, value) 28 | }) 29 | 30 | test('Can be shallow on non-objects nor arrays', (t) => { 31 | t.is(safeJsonValue(0n, { shallow: true }).value, undefined) 32 | }) 33 | -------------------------------------------------------------------------------- /src/object.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | import { transformProp } from './prop.js' 4 | 5 | // Recurse over object properties. 6 | // Omitted properties are completely ignored (as opposed to have a key but an 7 | // `undefined` value). 8 | // We iterate in `Reflect.ownKeys()` order, not in sorted keys order. 9 | // - This is faster 10 | // - This preserves the object properties order 11 | // Uses imperative logic for performance reasons. 12 | /* eslint-disable fp/no-let, fp/no-loops, fp/no-mutation, max-depth */ 13 | export const recurseObject = ({ 14 | object, 15 | changes, 16 | ancestors, 17 | path, 18 | size, 19 | maxSize, 20 | recurse, 21 | }) => { 22 | const newObject = getNewObject(object) 23 | let state = { empty: true, size } 24 | 25 | for (const key of Reflect.ownKeys(object)) { 26 | state = transformProp({ 27 | parent: object, 28 | changes, 29 | ancestors, 30 | path, 31 | maxSize, 32 | key, 33 | type: 'objectProp', 34 | empty: state.empty, 35 | size: state.size, 36 | recurse, 37 | }) 38 | 39 | if (state.value !== undefined) { 40 | newObject[key] = state.value 41 | } 42 | } 43 | 44 | addClassChange({ object, newObject, changes, path }) 45 | return { value: newObject, size: state.size } 46 | } 47 | /* eslint-enable fp/no-let, fp/no-loops, fp/no-mutation, max-depth */ 48 | 49 | // When the object has a `null` prototype, we keep it. 50 | // - This reduces the number of changes 51 | // - Also, `JSON.stringify()` handles those 52 | const getNewObject = (object) => 53 | Object.getPrototypeOf(object) === null ? Object.create(null) : {} 54 | 55 | // Inherited properties are omitted. 56 | // Therefore, classes are converted to plain objects. 57 | // - This mimics `JSON.stringify()` behavior 58 | const addClassChange = ({ object, newObject, changes, path }) => { 59 | if (!isPlainObj(object)) { 60 | // eslint-disable-next-line fp/no-mutating-methods 61 | changes.push({ 62 | path, 63 | oldValue: object, 64 | newValue: newObject, 65 | reason: 'unresolvedClass', 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/object.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import safeJsonValue from 'safe-json-value' 4 | 5 | test('Keep null prototypes', (t) => { 6 | const { value, changes } = safeJsonValue(Object.create(null)) 7 | t.deepEqual(value, {}) 8 | t.deepEqual(changes, []) 9 | t.is(Object.getPrototypeOf(value), null) 10 | }) 11 | 12 | test('Omit removed properties', (t) => { 13 | const { value, changes } = safeJsonValue({ prop: undefined }) 14 | t.deepEqual(value, {}) 15 | t.deepEqual(changes, [ 16 | { 17 | path: ['prop'], 18 | oldValue: undefined, 19 | newValue: undefined, 20 | reason: 'ignoredUndefined', 21 | }, 22 | ]) 23 | t.false('prop' in value) 24 | }) 25 | 26 | test('Convert any objects to plain objects', (t) => { 27 | const set = new Set([]) 28 | // eslint-disable-next-line fp/no-mutation 29 | set.prop = true 30 | t.deepEqual(safeJsonValue(set), { 31 | value: { prop: true }, 32 | changes: [ 33 | { 34 | path: [], 35 | oldValue: set, 36 | newValue: { prop: true }, 37 | reason: 'unresolvedClass', 38 | }, 39 | ], 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/prop.js: -------------------------------------------------------------------------------- 1 | import { safeGetProp } from './get.js' 2 | import { omitInvalidKey } from './key.js' 3 | import { addSize } from './size.js' 4 | 5 | // Transform an object property or an array item 6 | export const transformProp = ({ 7 | parent, 8 | changes, 9 | ancestors, 10 | path, 11 | maxSize, 12 | key, 13 | type, 14 | empty, 15 | size, 16 | recurse, 17 | }) => { 18 | const propPath = [...path, key] 19 | const { size: sizeA, stop } = addSize({ 20 | type, 21 | size, 22 | maxSize, 23 | changes, 24 | path: propPath, 25 | context: { empty, parent, key }, 26 | }) 27 | 28 | if (stop) { 29 | return { empty, size } 30 | } 31 | 32 | const { value, size: sizeB } = transformPropValue({ 33 | parent, 34 | key, 35 | changes, 36 | ancestors, 37 | path: propPath, 38 | size: sizeA, 39 | maxSize, 40 | recurse, 41 | }) 42 | return value === undefined 43 | ? { empty, size } 44 | : { empty: false, size: sizeB, value } 45 | } 46 | 47 | // Recurse over an object property or array index 48 | const transformPropValue = ({ 49 | parent, 50 | key, 51 | changes, 52 | ancestors, 53 | path, 54 | size, 55 | maxSize, 56 | recurse, 57 | }) => { 58 | const { prop, safe } = safeGetProp({ parent, key, changes, path }) 59 | 60 | if (!safe) { 61 | return { value: prop, size } 62 | } 63 | 64 | const { prop: propA, validKey } = omitInvalidKey({ 65 | parent, 66 | key, 67 | prop, 68 | changes, 69 | path, 70 | }) 71 | 72 | if (!validKey) { 73 | return { value: propA, size } 74 | } 75 | 76 | return recurse({ value: propA, changes, ancestors, path, size, maxSize }) 77 | } 78 | -------------------------------------------------------------------------------- /src/size.js: -------------------------------------------------------------------------------- 1 | import { safeGetChangeProp } from './get.js' 2 | 3 | // Apply `maxSize`, which omits values if they their JSON size would be too 4 | // high. 5 | // This is based on the JSON size without any indentation because: 6 | // - This removes the need for a `maxSizeIndentation` option 7 | // - If value is likely to be big, it is also likely to be serialized without 8 | // any indentation to be kept small 9 | // - The `maxSize` option is likely to be more of a soft limit than a hard 10 | // limit 11 | // - A hard limit is more likely when the value is a network request payload 12 | // (as opposed to be kept in-memory or as a file), but then it is likely 13 | // to be compressed too 14 | // Strings that are too long are completely omitted instead of being truncated: 15 | // - This is more consistent with the rest of the library 16 | // - The truncation might make the value syntactically invalid, e.g. if it is a 17 | // serialized value 18 | // - This allows checking for strings being too large with `=== undefined` 19 | // instead of inspecting the `changes` 20 | // The top-level value itself might become `undefined` if either: 21 | // - The `maxSize` option is very low (which is unlikely) 22 | // - The top-level value is a very long string 23 | // This is applied incrementally, in a depth-first manner, so that omitted 24 | // fields (due to being over `maxSize`) and their children are not processed 25 | // at all, for performance reason. 26 | export const addSize = ({ type, size, maxSize, changes, path, context }) => { 27 | if (maxSize === SKIP_MAX_SIZE) { 28 | return { size, stop: false } 29 | } 30 | 31 | const { getSize, getOldValue } = SIZED_TYPES[type] 32 | const newSize = size + getSize(context) 33 | const stop = newSize > maxSize 34 | 35 | if (!stop) { 36 | return { size: newSize, stop } 37 | } 38 | 39 | // eslint-disable-next-line fp/no-mutating-methods 40 | changes.push({ 41 | path, 42 | oldValue: getOldValue(context), 43 | newValue: undefined, 44 | reason: 'unsafeSize', 45 | }) 46 | return { size, stop } 47 | } 48 | 49 | // Skip checking for size when `maxSize` option equals this value 50 | const SKIP_MAX_SIZE = Number.POSITIVE_INFINITY 51 | 52 | // Default value for `maxSize` option. 53 | // Chosen based on v8 max string length, which is ~5e8, which is smaller than 54 | // SpiderMonkey (~1e9) and SquirrelFish (~2e9). 55 | export const DEFAULT_MAX_SIZE = 1e7 56 | 57 | const SIZED_TYPES = { 58 | value: { 59 | getSize: (value) => { 60 | if (value === undefined) { 61 | return 0 62 | } 63 | 64 | return typeof value === 'object' && value !== null 65 | ? 2 66 | : getJsonLength(value) 67 | }, 68 | getOldValue: (value) => value, 69 | }, 70 | arrayItem: { 71 | getSize: ({ empty }) => (empty ? 0 : 1), 72 | getOldValue: safeGetChangeProp, 73 | }, 74 | objectProp: { 75 | getSize: ({ key, empty }) => 76 | typeof key === 'symbol' ? 0 : getJsonLength(key) + (empty ? 1 : 2), 77 | getOldValue: safeGetChangeProp, 78 | }, 79 | } 80 | 81 | // We use `JSON.stringify()` to compute the length of strings (including 82 | // property keys) to take into account escaping, including: 83 | // - Control characters and Unicode characters 84 | // - Invalid Unicode sequences 85 | // This can throw if `value` is a large strings with many backslash sequences. 86 | // We use the character length instead of the UTF-8 bytes length: 87 | // - This is less proper 88 | // - However, this is much faster and is good enough for this specific purpose 89 | const getJsonLength = (value) => { 90 | try { 91 | return JSON.stringify(value).length 92 | } catch { 93 | return Number.POSITIVE_INFINITY 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/size.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | const strings = [ 7 | 'test', 8 | '', 9 | // Backslash sequences 10 | '\n', 11 | '\0', 12 | // UTF-8 character 13 | '𝌆', 14 | // Valid UTF-8 sequences 15 | '\uD834\uDF06', 16 | // Invalid UTF-8 sequences 17 | '\uDF06\uD834', 18 | '\uDEAD', 19 | ] 20 | 21 | each( 22 | [ 23 | {}, 24 | [], 25 | true, 26 | false, 27 | null, 28 | 0, 29 | // eslint-disable-next-line no-magic-numbers 30 | 0.1, 31 | -0, 32 | -1, 33 | // eslint-disable-next-line no-magic-numbers 34 | 1e60, 35 | // eslint-disable-next-line no-magic-numbers 36 | 1e-60, 37 | ...strings, 38 | ], 39 | ({ title }, input) => { 40 | test(`Applies options.maxSize on values | ${title}`, (t) => { 41 | const size = JSON.stringify(input).length 42 | t.deepEqual(safeJsonValue(input, { maxSize: size }), { 43 | value: input, 44 | changes: [], 45 | }) 46 | t.deepEqual(safeJsonValue(input, { maxSize: size - 1 }), { 47 | value: undefined, 48 | changes: [ 49 | { 50 | path: [], 51 | oldValue: input, 52 | newValue: undefined, 53 | reason: 'unsafeSize', 54 | }, 55 | ], 56 | }) 57 | }) 58 | }, 59 | ) 60 | 61 | each([...strings], ({ title }, key) => { 62 | test(`Applies options.maxSize on properties | ${title}`, (t) => { 63 | const input = { one: true, [key]: true } 64 | const size = JSON.stringify(input).length 65 | t.deepEqual(safeJsonValue(input, { maxSize: size }), { 66 | changes: [], 67 | value: input, 68 | }) 69 | t.deepEqual(safeJsonValue(input, { maxSize: size - 1 }), { 70 | value: { one: true }, 71 | changes: [ 72 | { 73 | path: [key], 74 | oldValue: true, 75 | newValue: undefined, 76 | reason: 'unsafeSize', 77 | }, 78 | ], 79 | }) 80 | }) 81 | }) 82 | 83 | const symbol = Symbol('test') 84 | each( 85 | [ 86 | { 87 | input: { one: undefined, prop: true }, 88 | output: { prop: true }, 89 | key: 'one', 90 | }, 91 | { input: [undefined, true], output: [true], key: 0 }, 92 | { 93 | input: { [symbol]: undefined, prop: true }, 94 | output: { prop: true }, 95 | key: symbol, 96 | reason: 'ignoredSymbolKey', 97 | }, 98 | ], 99 | ({ title }, { input, output, key, reason = 'ignoredUndefined' }) => { 100 | test(`Omitted values do not count towards options.maxSize | ${title}`, (t) => { 101 | const maxSize = JSON.stringify(output).length 102 | t.deepEqual(safeJsonValue(input, { maxSize }), { 103 | changes: [ 104 | { path: [key], oldValue: undefined, newValue: undefined, reason }, 105 | ], 106 | value: output, 107 | }) 108 | }) 109 | }, 110 | ) 111 | 112 | each( 113 | [ 114 | { 115 | input: { one: { two: { three: true, four: true } } }, 116 | output: { one: { two: { three: true } } }, 117 | path: ['one', 'two', 'four'], 118 | }, 119 | { 120 | input: { one: { four: true, two: { three: true, four: true } } }, 121 | output: { one: { four: true, two: { three: true } } }, 122 | path: ['one', 'two', 'four'], 123 | }, 124 | ], 125 | ({ title }, { input, output, path }) => { 126 | test(`Applies options.maxSize in a depth-first manner | ${title}`, (t) => { 127 | t.deepEqual( 128 | safeJsonValue(input, { maxSize: JSON.stringify(output).length }), 129 | { 130 | value: output, 131 | changes: [ 132 | { path, oldValue: true, newValue: undefined, reason: 'unsafeSize' }, 133 | ], 134 | }, 135 | ) 136 | }) 137 | }, 138 | ) 139 | 140 | const error = new Error('test') 141 | each( 142 | [ 143 | { 144 | input: { two: undefined }, 145 | output: {}, 146 | key: 'two', 147 | change: { reason: 'ignoredUndefined' }, 148 | }, 149 | { 150 | input: { one: true, two: undefined }, 151 | output: { one: true }, 152 | key: 'two', 153 | sizeIncrement: ','.length + JSON.stringify('two').length + ':'.length - 1, 154 | change: { reason: 'ignoredUndefined' }, 155 | }, 156 | { 157 | input: [undefined], 158 | output: [], 159 | key: 0, 160 | change: { reason: 'ignoredUndefined' }, 161 | sizeChange: { reason: 'ignoredUndefined' }, 162 | }, 163 | { 164 | input: [1, undefined], 165 | output: [1], 166 | key: 1, 167 | sizeIncrement: ','.length - 1, 168 | change: { reason: 'ignoredUndefined' }, 169 | }, 170 | { 171 | // eslint-disable-next-line fp/no-mutating-methods 172 | input: Object.defineProperty({}, 'prop', { 173 | get: () => { 174 | throw error 175 | }, 176 | enumerable: true, 177 | configurable: true, 178 | }), 179 | output: {}, 180 | key: 'prop', 181 | change: { reason: 'unsafeGetter', error }, 182 | title: 'unsafeObjectProp', 183 | }, 184 | ], 185 | ( 186 | { title }, 187 | { input, output, key, sizeIncrement = 0, change, sizeChange = {} }, 188 | ) => { 189 | test(`Does not recurse if object property key, property comma or array comma is over options.maxSize | ${title}`, (t) => { 190 | t.deepEqual(safeJsonValue(input), { 191 | value: output, 192 | changes: [ 193 | { path: [key], oldValue: undefined, newValue: undefined, ...change }, 194 | ], 195 | }) 196 | const maxSize = JSON.stringify(output).length + sizeIncrement 197 | t.deepEqual(safeJsonValue(input, { maxSize }), { 198 | value: output, 199 | changes: [ 200 | { 201 | path: [key], 202 | oldValue: undefined, 203 | newValue: undefined, 204 | reason: 'unsafeSize', 205 | ...sizeChange, 206 | }, 207 | ], 208 | }) 209 | }) 210 | }, 211 | ) 212 | 213 | const V8_MAX_STRING_LENGTH = 5e8 214 | const largeString = '\n'.repeat(V8_MAX_STRING_LENGTH) 215 | 216 | test('Handles very large strings', (t) => { 217 | const maxSize = JSON.stringify({ one: '' }).length 218 | t.deepEqual(safeJsonValue({ one: largeString }, { maxSize }), { 219 | value: {}, 220 | changes: [ 221 | { 222 | path: ['one'], 223 | oldValue: largeString, 224 | newValue: undefined, 225 | reason: 'unsafeSize', 226 | }, 227 | ], 228 | }) 229 | }) 230 | 231 | test('Handles very large object properties', (t) => { 232 | t.deepEqual(safeJsonValue({ [largeString]: true }, { maxSize: 2 }), { 233 | value: {}, 234 | changes: [ 235 | { 236 | path: [largeString], 237 | oldValue: true, 238 | newValue: undefined, 239 | reason: 'unsafeSize', 240 | }, 241 | ], 242 | }) 243 | }) 244 | 245 | test('Does not apply options.maxSize if infinite', (t) => { 246 | t.deepEqual( 247 | safeJsonValue(largeString, { maxSize: Number.POSITIVE_INFINITY }), 248 | { value: largeString, changes: [] }, 249 | ) 250 | }) 251 | 252 | each([undefined, { maxSize: undefined }], ({ title }, options) => { 253 | test(`Applies options.maxSize by default | ${title}`, (t) => { 254 | t.deepEqual(safeJsonValue(largeString, options), { 255 | value: undefined, 256 | changes: [ 257 | { 258 | path: [], 259 | oldValue: largeString, 260 | newValue: undefined, 261 | reason: 'unsafeSize', 262 | }, 263 | ], 264 | }) 265 | }) 266 | }) 267 | -------------------------------------------------------------------------------- /src/to_json.js: -------------------------------------------------------------------------------- 1 | import normalizeException from 'normalize-exception' 2 | 3 | import { isObject } from './is_object.js' 4 | 5 | // Replace `object.toJSON()` by its return value. 6 | // - Including for native classes like `Date` 7 | // This ensures this is resolved right now instead of during `JSON.stringify()`. 8 | // If the return value has `toJSON()` itself, it is ignored. 9 | // - Only on `object`, not deeply. 10 | // - This mimics `JSON.stringify()` behavior. 11 | // If `object.toJSON()` throws, `object` is omitted. 12 | // - As opposed to just ignoring `toJSON()` because this would mean it could 13 | // have different types/shapes depending on whether `toJSON()` throws 14 | export const callToJSON = (value, changes, path) => { 15 | if (!hasToJSON(value)) { 16 | return value 17 | } 18 | 19 | try { 20 | const toJSONResult = triggerToJSON(value) 21 | // eslint-disable-next-line fp/no-mutating-methods 22 | changes.push({ 23 | path, 24 | oldValue: value, 25 | newValue: toJSONResult, 26 | reason: 'unresolvedToJSON', 27 | }) 28 | return toJSONResult 29 | } catch (error) { 30 | // eslint-disable-next-line fp/no-mutating-methods 31 | changes.push({ 32 | path, 33 | oldValue: value, 34 | newValue: undefined, 35 | reason: 'unsafeToJSON', 36 | error: normalizeException(error), 37 | }) 38 | } 39 | } 40 | 41 | const hasToJSON = (value) => 42 | isObject(value) && 43 | 'toJSON' in value && 44 | typeof value.toJSON === 'function' && 45 | !TO_JSON_RECURSION.has(value) 46 | 47 | // We handle the common use case of an `object.toJSON()` calling this library 48 | // itself. 49 | // - We do so by keeping track of prevent infinite recursion with a WeakSet 50 | // - This only works if `this` (or an ancestor) is passed as argument to this 51 | // library (inside `object.toJSON()`), as reference (not copy) 52 | // - We do not set a symbol property instead, since this might change how 53 | // user-defined `object.toJSON()` behaves 54 | const triggerToJSON = (object) => { 55 | TO_JSON_RECURSION.add(object) 56 | 57 | try { 58 | return object.toJSON() 59 | } finally { 60 | TO_JSON_RECURSION.delete(object) 61 | } 62 | } 63 | 64 | // Uses a WeakSet instead of a Set since this is a top-level variable and we 65 | // want to make sure there are no memory leaks. 66 | const TO_JSON_RECURSION = new WeakSet([]) 67 | -------------------------------------------------------------------------------- /src/to_json.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | test('Calls object.toJSON()', (t) => { 7 | const input = { 8 | toJSON: () => true, 9 | } 10 | const { value, changes } = safeJsonValue(input) 11 | t.true(value) 12 | t.deepEqual(changes, [ 13 | { path: [], oldValue: input, newValue: true, reason: 'unresolvedToJSON' }, 14 | ]) 15 | }) 16 | 17 | test('Handles object.toJSON() returning undefined', (t) => { 18 | const input = { prop: { toJSON: () => {} } } 19 | const { value, changes } = safeJsonValue(input) 20 | t.deepEqual(value, {}) 21 | t.deepEqual(changes, [ 22 | { 23 | path: ['prop'], 24 | oldValue: input.prop, 25 | newValue: undefined, 26 | reason: 'unresolvedToJSON', 27 | }, 28 | { 29 | path: ['prop'], 30 | oldValue: undefined, 31 | newValue: undefined, 32 | reason: 'ignoredUndefined', 33 | }, 34 | ]) 35 | }) 36 | 37 | test('Handles object.toJSON() that throws', (t) => { 38 | const error = new Error('test') 39 | const input = { 40 | toJSON: () => { 41 | throw error.message 42 | }, 43 | } 44 | const { value, changes } = safeJsonValue(input) 45 | t.is(value, undefined) 46 | t.deepEqual(changes, [ 47 | { 48 | path: [], 49 | oldValue: input, 50 | newValue: undefined, 51 | reason: 'unsafeToJSON', 52 | error, 53 | }, 54 | { 55 | path: [], 56 | oldValue: undefined, 57 | newValue: undefined, 58 | reason: 'ignoredUndefined', 59 | }, 60 | ]) 61 | }) 62 | 63 | test('Handles object.toJSON that are not functions', (t) => { 64 | const input = { toJSON: true } 65 | const { value, changes } = safeJsonValue(input) 66 | t.deepEqual(value, input) 67 | t.deepEqual(changes, []) 68 | }) 69 | 70 | test('Handles dates', (t) => { 71 | const input = new Date() 72 | const newValue = input.toJSON() 73 | const { value, changes } = safeJsonValue(input) 74 | t.deepEqual(value, newValue) 75 | t.deepEqual(changes, [ 76 | { path: [], oldValue: input, newValue, reason: 'unresolvedToJSON' }, 77 | ]) 78 | }) 79 | 80 | test('Does not call object.toJSON() recursively', (t) => { 81 | const newValue = { toJSON: () => {}, prop: true } 82 | const input = { toJSON: () => newValue } 83 | const { value, changes } = safeJsonValue(input) 84 | t.deepEqual(value, { prop: true }) 85 | t.deepEqual(changes, [ 86 | { path: [], oldValue: input, newValue, reason: 'unresolvedToJSON' }, 87 | { 88 | path: ['toJSON'], 89 | oldValue: newValue.toJSON, 90 | newValue: undefined, 91 | reason: 'ignoredFunction', 92 | }, 93 | ]) 94 | }) 95 | 96 | const inputCallParent = { 97 | prop: { 98 | one: true, 99 | two: undefined, 100 | toJSON: () => safeJsonValue(inputCallParent).value, 101 | }, 102 | } 103 | 104 | const inputCallSelfCopy = { 105 | prop: { 106 | one: true, 107 | two: undefined, 108 | toJSON: () => safeJsonValue({ ...inputCallSelfCopy }).value, 109 | }, 110 | } 111 | 112 | const inputCallSelfRef = { 113 | one: true, 114 | two: undefined, 115 | toJSON: () => safeJsonValue(inputCallSelfRef).value, 116 | } 117 | 118 | each([inputCallParent, inputCallSelfCopy], ({ title }, input) => { 119 | test(`Handles object.toJSON() that call the library itself with a parent or a copy | ${title}`, (t) => { 120 | const value = input.prop.toJSON() 121 | t.false('two' in value.prop) 122 | t.false('toJSON' in value.prop) 123 | t.deepEqual(value, { prop: { prop: { one: true } } }) 124 | }) 125 | }) 126 | 127 | test('Handles object.toJSON() that calls the library itself', (t) => { 128 | const value = inputCallSelfRef.toJSON() 129 | t.false('two' in value) 130 | t.false('toJSON' in value) 131 | t.deepEqual(value, { one: true }) 132 | }) 133 | -------------------------------------------------------------------------------- /src/type.js: -------------------------------------------------------------------------------- 1 | // Omit types that are not supported by `JSON.stringify()`: 2 | // - bigints would throw 3 | // - `NaN` and `[-]Infinity` would be transformed to `null` 4 | // - `undefined`, functions and symbols would be omitted 5 | export const omitInvalidTypes = (value, changes, path) => { 6 | const reason = getInvalidTypeReason(value) 7 | 8 | if (reason === undefined) { 9 | return value 10 | } 11 | 12 | // eslint-disable-next-line fp/no-mutating-methods 13 | changes.push({ path, oldValue: value, newValue: undefined, reason }) 14 | } 15 | 16 | const getInvalidTypeReason = (value) => { 17 | const type = typeof value 18 | const reason = INVALID_TYPES[type] 19 | 20 | if (reason !== undefined) { 21 | return reason 22 | } 23 | 24 | if (type === 'number' && !Number.isFinite(value)) { 25 | return 'unstableInfinite' 26 | } 27 | } 28 | 29 | const INVALID_TYPES = { 30 | function: 'ignoredFunction', 31 | symbol: 'ignoredSymbolValue', 32 | undefined: 'ignoredUndefined', 33 | bigint: 'unsafeBigInt', 34 | } 35 | -------------------------------------------------------------------------------- /src/type.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | each( 7 | [ 8 | { value: () => {}, reason: 'ignoredFunction' }, 9 | { value: Symbol('test'), reason: 'ignoredSymbolValue' }, 10 | { value: undefined, reason: 'ignoredUndefined' }, 11 | { value: 0n, reason: 'unsafeBigInt' }, 12 | { value: Number.NaN, reason: 'unstableInfinite' }, 13 | { value: Number.POSITIVE_INFINITY, reason: 'unstableInfinite' }, 14 | { value: Number.NEGATIVE_INFINITY, reason: 'unstableInfinite' }, 15 | ], 16 | [ 17 | { getInput: (value) => value, output: undefined, change: { path: [] } }, 18 | { 19 | getInput: (value) => ({ prop: value }), 20 | output: {}, 21 | change: { path: ['prop'] }, 22 | }, 23 | ], 24 | ({ title }, { value, reason }, { getInput, output, change }) => { 25 | test(`Omit invalid types | ${title}`, (t) => { 26 | const input = getInput(value) 27 | t.deepEqual(safeJsonValue(input), { 28 | value: output, 29 | changes: [{ ...change, oldValue: value, newValue: undefined, reason }], 30 | }) 31 | }) 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /src/uncaught.js: -------------------------------------------------------------------------------- 1 | import normalizeException from 'normalize-exception' 2 | 3 | // When dynamic functions (`object.toJSON()`, `get` method or Proxy hook): 4 | // - Returns new objects (as opposed to reference to existing objects) 5 | // - That contains properties with dynamic functions themselves 6 | // It is not possible to detect whether the recursion will be infinite or not, 7 | // except by catching any exception due to a stack overflow. 8 | // - The property is omitted then 9 | // One downside is that it also catches any bug in this library. 10 | // - However the guarantee that this library never throws is more important. 11 | // Note: there is still one edge case which might crash the process (with 12 | // a memory heap crash): 13 | // - When a `get` method or Proxy hook (not `object.toJSON()`) 14 | // - Calls this library itself 15 | // - Passing a reference (not a copy) to itself or to an ancestor 16 | export const handleUnsafeException = ({ 17 | value, 18 | changes, 19 | path, 20 | error, 21 | size, 22 | }) => { 23 | // eslint-disable-next-line fp/no-mutating-methods 24 | changes.push({ 25 | path, 26 | oldValue: value, 27 | newValue: undefined, 28 | reason: 'unsafeException', 29 | error: normalizeException(error), 30 | }) 31 | return { value: undefined, size } 32 | } 33 | -------------------------------------------------------------------------------- /src/uncaught.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import safeJsonValue from 'safe-json-value' 5 | 6 | const isProp = (key) => key.startsWith('prop') 7 | 8 | const getInfiniteGetter = () => ({ 9 | // eslint-disable-next-line fp/no-get-set 10 | get prop() { 11 | return getInfiniteGetter() 12 | }, 13 | }) 14 | 15 | const getInfiniteToJSON = () => ({ 16 | prop: true, 17 | toJSON: () => ({ prop: getInfiniteToJSON() }), 18 | }) 19 | 20 | const getInfiniteToJSONTwo = () => ({ 21 | toJSON: () => ({ prop: true, propTwo: getInfiniteToJSONTwo() }), 22 | }) 23 | 24 | each( 25 | [ 26 | { getInput: getInfiniteGetter }, 27 | { getInput: getInfiniteToJSON }, 28 | { getInput: getInfiniteToJSONTwo }, 29 | ], 30 | ({ title }, { getInput }) => { 31 | test(`Handle dynamic infinite functions | ${title}`, (t) => { 32 | const input = getInput() 33 | const { value, changes } = safeJsonValue(input) 34 | t.true('prop' in value) 35 | const lastChange = changes.at(-1) 36 | t.true(Array.isArray(lastChange.path) && lastChange.path.every(isProp)) 37 | t.is(typeof lastChange.oldValue, 'object') 38 | t.is(lastChange.newValue, undefined) 39 | t.is(lastChange.reason, 'unsafeException') 40 | }) 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ehmicky/dev-tasks/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------