├── .all-contributorsrc ├── .eslintrc.cjs ├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .publishrc ├── LICENSE ├── README.md ├── benchmark.js ├── docs └── superjson-banner.png ├── package.json ├── src ├── accessDeep.test.ts ├── accessDeep.ts ├── class-registry.ts ├── custom-transformer-registry.ts ├── double-indexed-kv.ts ├── index.test.ts ├── index.ts ├── is.test.ts ├── is.ts ├── non-deduped-cal.json ├── pathstringifier.test.ts ├── pathstringifier.ts ├── plainer.spec.ts ├── plainer.ts ├── registry.test.ts ├── registry.ts ├── transformer.test.ts ├── transformer.ts ├── types.ts └── util.ts ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "merelinguist", 10 | "name": "Dylan Brookes", 11 | "avatar_url": "https://avatars3.githubusercontent.com/u/24858006?v=4", 12 | "profile": "https://github.com/merelinguist", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "design", 17 | "test" 18 | ] 19 | }, 20 | { 21 | "login": "Skn0tt", 22 | "name": "Simon Knott", 23 | "avatar_url": "https://avatars1.githubusercontent.com/u/14912729?v=4", 24 | "profile": "http://simonknott.de", 25 | "contributions": [ 26 | "code", 27 | "ideas", 28 | "test", 29 | "doc" 30 | ] 31 | }, 32 | { 33 | "login": "flybayer", 34 | "name": "Brandon Bayer", 35 | "avatar_url": "https://avatars3.githubusercontent.com/u/8813276?v=4", 36 | "profile": "https://twitter.com/flybayer", 37 | "contributions": [ 38 | "ideas" 39 | ] 40 | }, 41 | { 42 | "login": "mrleebo", 43 | "name": "Jeremy Liberman", 44 | "avatar_url": "https://avatars3.githubusercontent.com/u/2754163?v=4", 45 | "profile": "http://jeremyliberman.com/", 46 | "contributions": [ 47 | "test", 48 | "code" 49 | ] 50 | }, 51 | { 52 | "login": "jorisre", 53 | "name": "Joris", 54 | "avatar_url": "https://avatars1.githubusercontent.com/u/7545547?v=4", 55 | "profile": "https://github.com/jorisre", 56 | "contributions": [ 57 | "code" 58 | ] 59 | }, 60 | { 61 | "login": "tomhooijenga", 62 | "name": "tomhooijenga", 63 | "avatar_url": "https://avatars0.githubusercontent.com/u/1853235?v=4", 64 | "profile": "https://github.com/tomhooijenga", 65 | "contributions": [ 66 | "code", 67 | "bug" 68 | ] 69 | }, 70 | { 71 | "login": "ftonato", 72 | "name": "Ademílson F. Tonato", 73 | "avatar_url": "https://avatars2.githubusercontent.com/u/5417662?v=4", 74 | "profile": "https://twitter.com/ftonato", 75 | "contributions": [ 76 | "test" 77 | ] 78 | }, 79 | { 80 | "login": "hasparus", 81 | "name": "Piotr Monwid-Olechnowicz", 82 | "avatar_url": "https://avatars0.githubusercontent.com/u/15332326?v=4", 83 | "profile": "https://haspar.us", 84 | "contributions": [ 85 | "ideas" 86 | ] 87 | }, 88 | { 89 | "login": "KATT", 90 | "name": "Alex Johansson", 91 | "avatar_url": "https://avatars1.githubusercontent.com/u/459267?v=4", 92 | "profile": "http://kattcorp.com", 93 | "contributions": [ 94 | "code", 95 | "test" 96 | ] 97 | }, 98 | { 99 | "login": "simonedelmann", 100 | "name": "Simon Edelmann", 101 | "avatar_url": "https://avatars.githubusercontent.com/u/2821076?v=4", 102 | "profile": "https://github.com/simonedelmann", 103 | "contributions": [ 104 | "bug", 105 | "code", 106 | "ideas" 107 | ] 108 | }, 109 | { 110 | "login": "samtgarson", 111 | "name": "Sam Garson", 112 | "avatar_url": "https://avatars.githubusercontent.com/u/6242344?v=4", 113 | "profile": "https://www.samgarson.com", 114 | "contributions": [ 115 | "bug" 116 | ] 117 | }, 118 | { 119 | "login": "markhughes", 120 | "name": "Mark Hughes", 121 | "avatar_url": "https://avatars.githubusercontent.com/u/1357323?v=4", 122 | "profile": "http://twitter.com/_markeh", 123 | "contributions": [ 124 | "bug" 125 | ] 126 | }, 127 | { 128 | "login": "Lxxyx", 129 | "name": "Lxxyx", 130 | "avatar_url": "https://avatars.githubusercontent.com/u/13161470?v=4", 131 | "profile": "https://blog.lxxyx.cn/", 132 | "contributions": [ 133 | "code" 134 | ] 135 | }, 136 | { 137 | "login": "ElMassimo", 138 | "name": "Máximo Mussini", 139 | "avatar_url": "https://avatars.githubusercontent.com/u/1158253?v=4", 140 | "profile": "http://maximomussini.com", 141 | "contributions": [ 142 | "code" 143 | ] 144 | }, 145 | { 146 | "login": "PeterDekkers", 147 | "name": "Peter Dekkers", 148 | "avatar_url": "https://avatars.githubusercontent.com/u/425971?v=4", 149 | "profile": "https://goodcode.nz", 150 | "contributions": [ 151 | "bug" 152 | ] 153 | }, 154 | { 155 | "login": "goleary", 156 | "name": "Gabe O'Leary", 157 | "avatar_url": "https://avatars.githubusercontent.com/u/16123225?v=4", 158 | "profile": "http://goleary.com", 159 | "contributions": [ 160 | "doc" 161 | ] 162 | }, 163 | { 164 | "login": "binajmen", 165 | "name": "Benjamin", 166 | "avatar_url": "https://avatars.githubusercontent.com/u/15611419?v=4", 167 | "profile": "https://github.com/binajmen", 168 | "contributions": [ 169 | "doc" 170 | ] 171 | }, 172 | { 173 | "login": "icflorescu", 174 | "name": "Ionut-Cristian Florescu", 175 | "avatar_url": "https://avatars.githubusercontent.com/u/581999?v=4", 176 | "profile": "https://www.linkedin.com/in/icflorescu", 177 | "contributions": [ 178 | "bug" 179 | ] 180 | }, 181 | { 182 | "login": "chrisj-back2work", 183 | "name": "Chris Johnson", 184 | "avatar_url": "https://avatars.githubusercontent.com/u/68551954?v=4", 185 | "profile": "https://github.com/chrisj-back2work", 186 | "contributions": [ 187 | "doc" 188 | ] 189 | }, 190 | { 191 | "login": "nicholaschiang", 192 | "name": "Nicholas Chiang", 193 | "avatar_url": "https://avatars.githubusercontent.com/u/20798889?v=4", 194 | "profile": "https://nicholaschiang.com", 195 | "contributions": [ 196 | "bug", 197 | "code" 198 | ] 199 | }, 200 | { 201 | "login": "datner", 202 | "name": "Datner", 203 | "avatar_url": "https://avatars.githubusercontent.com/u/22598347?v=4", 204 | "profile": "https://github.com/datner", 205 | "contributions": [ 206 | "code" 207 | ] 208 | }, 209 | { 210 | "login": "ruessej", 211 | "name": "ruessej", 212 | "avatar_url": "https://avatars.githubusercontent.com/u/85690286?v=4", 213 | "profile": "https://github.com/ruessej", 214 | "contributions": [ 215 | "bug" 216 | ] 217 | }, 218 | { 219 | "login": "orionmiz", 220 | "name": "JH.Lee", 221 | "avatar_url": "https://avatars.githubusercontent.com/u/39466936?v=4", 222 | "profile": "https://jins.dev", 223 | "contributions": [ 224 | "doc" 225 | ] 226 | }, 227 | { 228 | "login": "narumincho", 229 | "name": "narumincho", 230 | "avatar_url": "https://avatars.githubusercontent.com/u/16481886?v=4", 231 | "profile": "https://narumincho.notion.site", 232 | "contributions": [ 233 | "code" 234 | ] 235 | }, 236 | { 237 | "login": "mgreystone", 238 | "name": "Markus Greystone", 239 | "avatar_url": "https://avatars.githubusercontent.com/u/12430681?v=4", 240 | "profile": "https://github.com/mgreystone", 241 | "contributions": [ 242 | "bug" 243 | ] 244 | }, 245 | { 246 | "login": "darthmaim", 247 | "name": "darthmaim", 248 | "avatar_url": "https://avatars.githubusercontent.com/u/2511547?v=4", 249 | "profile": "https://gw2treasures.com/", 250 | "contributions": [ 251 | "code" 252 | ] 253 | }, 254 | { 255 | "login": "benjick", 256 | "name": "Max Malm", 257 | "avatar_url": "https://avatars.githubusercontent.com/u/430872?v=4", 258 | "profile": "http://www.maxmalm.se", 259 | "contributions": [ 260 | "doc" 261 | ] 262 | }, 263 | { 264 | "login": "tylercollier", 265 | "name": "Tyler Collier", 266 | "avatar_url": "https://avatars.githubusercontent.com/u/366538?v=4", 267 | "profile": "https://github.com/tylercollier", 268 | "contributions": [ 269 | "doc" 270 | ] 271 | }, 272 | { 273 | "login": "kidqueb", 274 | "name": "Nick Quebbeman", 275 | "avatar_url": "https://avatars.githubusercontent.com/u/884128?v=4", 276 | "profile": "https://github.com/kidqueb", 277 | "contributions": [ 278 | "doc" 279 | ] 280 | }, 281 | { 282 | "login": "tmcw", 283 | "name": "Tom MacWright", 284 | "avatar_url": "https://avatars.githubusercontent.com/u/32314?v=4", 285 | "profile": "https://macwright.com/", 286 | "contributions": [ 287 | "bug", 288 | "code" 289 | ] 290 | }, 291 | { 292 | "login": "peterbud", 293 | "name": "Peter Budai", 294 | "avatar_url": "https://avatars.githubusercontent.com/u/7863452?v=4", 295 | "profile": "https://github.com/peterbud", 296 | "contributions": [ 297 | "bug" 298 | ] 299 | } 300 | ], 301 | "badgeTemplate": "-orange.svg?style=flat-square\" alt=\"All Contributors\"/>", 302 | "contributorsPerLine": 7, 303 | "projectName": "superjson", 304 | "projectOwner": "blitz-js", 305 | "repoType": "github", 306 | "repoHost": "https://github.com", 307 | "skipCi": true, 308 | "commitConvention": "angular", 309 | "commitType": "docs" 310 | } 311 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['es5'], 3 | rules: { 4 | 'es5/no-for-of': 'error', 5 | 'es5/no-generators': 'error', 6 | 'es5/no-typeof-symbol': 'error', 7 | 'es5/no-es6-methods': 'error', 8 | 'es5/no-es6-static-methods': [ 9 | 'error', 10 | { 11 | exceptMethods: ['Object.assign'], 12 | }, 13 | ], 14 | "no-restricted-syntax": [ 15 | "error", 16 | "ForInStatement" 17 | ] 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @merelinguist 2 | * @Skn0tt 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | strategy: 8 | matrix: 9 | node: [20.x, 18.x, 16.x] 10 | 11 | env: 12 | CI: true 13 | YARN_IGNORE_ENGINES: true 14 | 15 | steps: 16 | - name: Begin CI... 17 | uses: actions/checkout@v2 18 | 19 | - name: Use Node 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node }} 23 | 24 | - name: Use cached node_modules 25 | uses: actions/cache@v2 26 | with: 27 | path: node_modules 28 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | nodeModules- 31 | 32 | - name: Install dependencies 33 | run: yarn install --frozen-lockfile 34 | 35 | - name: Lint 36 | run: yarn lint 37 | 38 | - name: Test 39 | run: yarn test 40 | 41 | - name: Build 42 | run: yarn build 43 | 44 | - name: Run benchmark 45 | run: node benchmark.js | tee output.txt 46 | env: 47 | NODE_ENV: production 48 | 49 | - name: Store benchmark result 50 | uses: rhysd/github-action-benchmark@v1 51 | with: 52 | tool: 'benchmarkjs' 53 | output-file-path: output.txt 54 | comment-on-alert: true 55 | save-data-file: ${{ github.ref == 'refs/heads/main' }} 56 | github-token: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn tsdx lint 5 | -------------------------------------------------------------------------------- /.publishrc: -------------------------------------------------------------------------------- 1 | { 2 | "validations": { 3 | "branch": "/(main)/", 4 | "vulnerableDependencies": false, 5 | "sensitiveData": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Simon Knott and superjson contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | superjson 3 |

4 | 5 |

6 | Safely serialize JavaScript expressions to a superset of JSON, which includes Dates, BigInts, and more. 7 |

8 | 9 |

10 | 11 | All Contributors 12 | 13 | 14 | npm 15 | 16 | 17 | Language grade: JavaScript 21 | 22 | 23 | 24 | CI 28 | 29 |

30 | 31 | ## Key features 32 | 33 | - 🍱 Reliable serialization and deserialization 34 | - 🔐 Type safety with autocompletion 35 | - 🐾 Negligible runtime footprint 36 | - 💫 Framework agnostic 37 | - 🛠 Perfect fix for Next.js's serialisation limitations in `getServerSideProps` and `getInitialProps` 38 | 39 | ## Backstory 40 | 41 | At [Blitz](https://github.com/blitz-js/blitz), we have struggled with the limitations of JSON. We often find ourselves working with `Date`, `Map`, `Set` or `BigInt`, but `JSON.stringify` doesn't support any of them without going through the hassle of converting manually! 42 | 43 | Superjson solves these issues by providing a thin wrapper over `JSON.stringify` and `JSON.parse`. 44 | 45 | ## Sponsors 46 | 47 | [Flightcontrol Logo](https://www.flightcontrol.dev/?ref=superjson) 48 | 49 | Superjson logo by [NUMI](https://github.com/numi-hq/open-design): 50 | 51 | [NUMI Logo](https://numi.tech/?ref=superjson) 52 | 53 | ## Getting started 54 | 55 | Install the library with your package manager of choice, e.g.: 56 | 57 | ``` 58 | yarn add superjson 59 | ``` 60 | 61 | ## Basic Usage 62 | 63 | The easiest way to use Superjson is with its `stringify` and `parse` functions. If you know how to use `JSON.stringify`, you already know Superjson! 64 | 65 | Easily stringify any expression you’d like: 66 | 67 | ```js 68 | import superjson from 'superjson'; 69 | 70 | const jsonString = superjson.stringify({ date: new Date(0) }); 71 | 72 | // jsonString === '{"json":{"date":"1970-01-01T00:00:00.000Z"},"meta":{"values":{date:"Date"}}}' 73 | ``` 74 | 75 | And parse your JSON like so: 76 | 77 | ```js 78 | const object = superjson.parse< 79 | { date: Date } 80 | >(jsonString); 81 | 82 | // object === { date: new Date(0) } 83 | ``` 84 | 85 | ## Advanced Usage 86 | 87 | For cases where you want lower level access to the `json` and `meta` data in the output, you can use the `serialize` and `deserialize` functions. 88 | 89 | One great use case for this is where you have an API that you want to be JSON compatible for all clients, but you still also want to transmit the meta data so clients can use superjson to fully deserialize it. 90 | 91 | For example: 92 | 93 | ```js 94 | const object = { 95 | normal: 'string', 96 | timestamp: new Date(), 97 | test: /superjson/, 98 | }; 99 | 100 | const { json, meta } = superjson.serialize(object); 101 | 102 | /* 103 | json = { 104 | normal: 'string', 105 | timestamp: "2020-06-20T04:56:50.293Z", 106 | test: "/superjson/", 107 | }; 108 | 109 | // note that `normal` is not included here; `meta` only has special cases 110 | meta = { 111 | values: { 112 | timestamp: ['Date'], 113 | test: ['regexp'], 114 | } 115 | }; 116 | */ 117 | ``` 118 | 119 | ## Using with Next.js 120 | 121 | The `getServerSideProps`, `getInitialProps`, and `getStaticProps` data hooks provided by Next.js do not allow you to transmit Javascript objects like Dates. It will error unless you convert Dates to strings, etc. 122 | 123 | Thankfully, Superjson is a perfect tool to bypass that limitation! 124 | 125 | ### Next.js SWC Plugin (experimental, v13 or above) 126 | 127 | Next.js SWC plugins are [experimental](https://nextjs.org/docs/advanced-features/compiler#swc-plugins-experimental), but promise a significant speedup. 128 | To use the [SuperJSON SWC plugin](https://github.com/blitz-js/next-superjson-plugin), install it and add it to your `next.config.js`: 129 | 130 | ```sh 131 | yarn add next-superjson-plugin 132 | ``` 133 | 134 | ```js 135 | // next.config.js 136 | module.exports = { 137 | experimental: { 138 | swcPlugins: [ 139 | [ 140 | 'next-superjson-plugin', 141 | { 142 | excluded: [], 143 | }, 144 | ], 145 | ], 146 | }, 147 | }; 148 | ``` 149 | 150 | ### Next.js (stable Babel transform) 151 | 152 | Install the library with your package manager of choice, e.g.: 153 | 154 | ```sh 155 | yarn add babel-plugin-superjson-next 156 | ``` 157 | 158 | Add the plugin to your .babelrc. If you don't have one, create it. 159 | 160 | ```js 161 | { 162 | "presets": ["next/babel"], 163 | "plugins": [ 164 | ... 165 | "superjson-next" // 👈 166 | ] 167 | } 168 | ``` 169 | 170 | Done! Now you can safely use all JS datatypes in your `getServerSideProps` / etc. . 171 | 172 | ## API 173 | 174 | ### serialize 175 | 176 | Serializes any JavaScript value into a JSON-compatible object. 177 | 178 | #### Examples 179 | 180 | ```js 181 | const object = { 182 | normal: 'string', 183 | timestamp: new Date(), 184 | test: /superjson/, 185 | }; 186 | 187 | const { json, meta } = serialize(object); 188 | ``` 189 | 190 | Returns **`json` and `meta`, both JSON-compatible values.** 191 | 192 | ## deserialize 193 | 194 | Deserializes the output of Superjson back into your original value. 195 | 196 | #### Examples 197 | 198 | ```js 199 | const { json, meta } = serialize(object); 200 | 201 | deserialize({ json, meta }); 202 | ``` 203 | 204 | Returns **`your original value`**. 205 | 206 | ### stringify 207 | 208 | Serializes and then stringifies your JavaScript value. 209 | 210 | #### Examples 211 | 212 | ```js 213 | const object = { 214 | normal: 'string', 215 | timestamp: new Date(), 216 | test: /superjson/, 217 | }; 218 | 219 | const jsonString = stringify(object); 220 | ``` 221 | 222 | Returns **`string`**. 223 | 224 | ### parse 225 | 226 | Parses and then deserializes the JSON string returned by `stringify`. 227 | 228 | #### Examples 229 | 230 | ```js 231 | const jsonString = stringify(object); 232 | 233 | parse(jsonString); 234 | ``` 235 | 236 | Returns **`your original value`**. 237 | 238 | --- 239 | 240 | Superjson supports many extra types which JSON does not. You can serialize all these: 241 | 242 | | type | supported by standard JSON? | supported by Superjson? | 243 | | ----------- | --------------------------- | ----------------------- | 244 | | `string` | ✅ | ✅ | 245 | | `number` | ✅ | ✅ | 246 | | `boolean` | ✅ | ✅ | 247 | | `null` | ✅ | ✅ | 248 | | `Array` | ✅ | ✅ | 249 | | `Object` | ✅ | ✅ | 250 | | `undefined` | ❌ | ✅ | 251 | | `bigint` | ❌ | ✅ | 252 | | `Date` | ❌ | ✅ | 253 | | `RegExp` | ❌ | ✅ | 254 | | `Set` | ❌ | ✅ | 255 | | `Map` | ❌ | ✅ | 256 | | `Error` | ❌ | ✅ | 257 | | `URL` | ❌ | ✅ | 258 | 259 | ## Recipes 260 | 261 | SuperJSON by default only supports built-in data types to keep bundle-size as low as possible. 262 | Here are some recipes you can use to extend to non-default data types. 263 | 264 | Place them in some central utility file and make sure they're executed before any other `SuperJSON` calls. 265 | In a Next.js project, `_app.ts` would be a good spot for that. 266 | 267 | ### `Decimal.js` / `Prisma.Decimal` 268 | 269 | ```ts 270 | import { Decimal } from 'decimal.js'; 271 | 272 | SuperJSON.registerCustom( 273 | { 274 | isApplicable: (v): v is Decimal => Decimal.isDecimal(v), 275 | serialize: v => v.toJSON(), 276 | deserialize: v => new Decimal(v), 277 | }, 278 | 'decimal.js' 279 | ); 280 | ``` 281 | 282 | ## Contributors ✨ 283 | 284 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 |
Dylan Brookes
Dylan Brookes

💻 📖 🎨 ⚠️
Simon Knott
Simon Knott

💻 🤔 ⚠️ 📖
Brandon Bayer
Brandon Bayer

🤔
Jeremy Liberman
Jeremy Liberman

⚠️ 💻
Joris
Joris

💻
tomhooijenga
tomhooijenga

💻 🐛
Ademílson F. Tonato
Ademílson F. Tonato

⚠️
Piotr Monwid-Olechnowicz
Piotr Monwid-Olechnowicz

🤔
Alex Johansson
Alex Johansson

💻 ⚠️
Simon Edelmann
Simon Edelmann

🐛 💻 🤔
Sam Garson
Sam Garson

🐛
Mark Hughes
Mark Hughes

🐛
Lxxyx
Lxxyx

💻
Máximo Mussini
Máximo Mussini

💻
Peter Dekkers
Peter Dekkers

🐛
Gabe O'Leary
Gabe O'Leary

📖
Benjamin
Benjamin

📖
Ionut-Cristian Florescu
Ionut-Cristian Florescu

🐛
Chris Johnson
Chris Johnson

📖
Nicholas Chiang
Nicholas Chiang

🐛 💻
Datner
Datner

💻
ruessej
ruessej

🐛
JH.Lee
JH.Lee

📖
narumincho
narumincho

💻
Markus Greystone
Markus Greystone

🐛
darthmaim
darthmaim

💻
Max Malm
Max Malm

📖
Tyler Collier
Tyler Collier

📖
Nick Quebbeman
Nick Quebbeman

📖
Tom MacWright
Tom MacWright

🐛 💻
Peter Budai
Peter Budai

🐛
334 | 335 | 336 | 337 | 338 | 339 | 340 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 341 | 342 | ## See also 343 | 344 | Other libraries that aim to solve a similar problem: 345 | 346 | - [Serialize JavaScript](https://github.com/yahoo/serialize-javascript) by Eric Ferraiuolo 347 | - [devalue](https://github.com/Rich-Harris/devalue) by Rich Harris 348 | - [next-json](https://github.com/iccicci/next-json) by Daniele Ricci 349 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark" 2 | import SuperJSON from "./dist/index.js" 3 | 4 | const instances = { 5 | 'toy example': { 6 | a: new Map([ 7 | [1, NaN], 8 | [2, null], 9 | [3, 'Hurray'], 10 | ]), 11 | a: /regexp/g, 12 | b: [new Set([1, 2, 3])], 13 | }, 14 | 'user graph': { 15 | users: new Map([ 16 | [ 17 | 'abcde', 18 | { 19 | id: 'abcde', 20 | created: new Date(2020), 21 | friendIds: new Set(['a', 'b', 'c']), 22 | }, 23 | ], 24 | [ 25 | 'dasdfa', 26 | { 27 | id: 'dasdfa', 28 | created: new Date(2019), 29 | friendIds: new Set(['b', 'c']), 30 | }, 31 | ], 32 | [ 33 | 'hu-ha-hu', 34 | { 35 | id: 'hu-ha-hu', 36 | created: new Date(2018), 37 | friendIds: new Set(['b', 'c', 'd', 'f']), 38 | }, 39 | ], 40 | [ 41 | 'umphrey', 42 | { id: 'umphrey', created: new Date(2017), friendIds: new Set([]) }, 43 | ], 44 | ]), 45 | }, 46 | 'deep nested': (() => { 47 | const data = []; 48 | for (let i = 0; i < 100; i++) { 49 | let nested1 = []; 50 | let nested2 = []; 51 | for (let j = 0; j < 10; j++) { 52 | nested1[j] = { 53 | createdAt: new Date(), 54 | updatedAt: new Date(), 55 | innerNested: { 56 | createdAt: new Date(), 57 | updatedAt: new Date(), 58 | }, 59 | }; 60 | nested2[j] = { 61 | createdAt: new Date(), 62 | updatedAt: new Date(), 63 | innerNested: { 64 | createdAt: new Date(), 65 | updatedAt: new Date(), 66 | }, 67 | }; 68 | } 69 | const object = { 70 | createdAt: new Date(), 71 | updatedAt: new Date(), 72 | nested1, 73 | nested2, 74 | }; 75 | data.push(object); 76 | } 77 | return data; 78 | })(), 79 | }; 80 | 81 | const suite = new Benchmark.Suite('serialize & deserialize'); 82 | 83 | for (const [key, instance] of Object.entries(instances)) { 84 | suite.add(key, () => { 85 | SuperJSON.deserialize(SuperJSON.serialize(instance)); 86 | }); 87 | } 88 | 89 | suite.on('cycle', event => { 90 | console.log('' + event.target); 91 | }); 92 | 93 | suite.run(); 94 | -------------------------------------------------------------------------------- /docs/superjson-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flightcontrolhq/superjson/0ac86f0ad70c9770471c1c4c4006b4e46381fa83/docs/superjson-banner.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.2.2", 3 | "license": "MIT", 4 | "type": "module", 5 | "typings": "dist/index.d.ts", 6 | "main": "./dist/index.js", 7 | "exports": { 8 | ".": "./dist/index.js" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "engines": { 14 | "node": ">=16" 15 | }, 16 | "scripts": { 17 | "build": "tsc", 18 | "test": "vitest run", 19 | "lint": "tsdx lint", 20 | "prepack": "yarn build", 21 | "prepare": "husky install", 22 | "publish-please": "publish-please", 23 | "prepublishOnly": "publish-please guard" 24 | }, 25 | "importSort": { 26 | ".ts": { 27 | "style": "module" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "superjson", 37 | "author": { 38 | "name": "Simon Knott", 39 | "email": "info@simonknott.de", 40 | "url": "https://simonknott.de" 41 | }, 42 | "contributors": [ 43 | { 44 | "name": "Dylan Brookes", 45 | "email": "dylan@brookes.net", 46 | "url": "https://github.com/merelinguist" 47 | }, 48 | { 49 | "name": "Brandon Bayer", 50 | "email": "b@bayer.w", 51 | "url": "https://twitter.com/flybayer" 52 | } 53 | ], 54 | "repository": { 55 | "type": "git", 56 | "url": "https://github.com/blitz-js/superjson" 57 | }, 58 | "devDependencies": { 59 | "@types/debug": "^4.1.5", 60 | "@types/mongodb": "^3.6.12", 61 | "@types/node": "^18.7.18", 62 | "benchmark": "^2.1.4", 63 | "decimal.js": "^10.3.1", 64 | "eslint-plugin-es5": "^1.5.0", 65 | "husky": "^6.0.0", 66 | "mongodb": "^3.6.6", 67 | "publish-please": "^5.5.2", 68 | "tsdx": "^0.14.1", 69 | "typescript": "^4.2.4", 70 | "vitest": "^0.34.6" 71 | }, 72 | "dependencies": { 73 | "copy-anything": "^3.0.2" 74 | }, 75 | "resolutions": { 76 | "**/@typescript-eslint/eslint-plugin": "^4.11.1", 77 | "**/@typescript-eslint/parser": "^4.11.1" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/accessDeep.test.ts: -------------------------------------------------------------------------------- 1 | import { setDeep } from './accessDeep.js'; 2 | 3 | import { describe, it, expect } from 'vitest'; 4 | 5 | describe('setDeep', () => { 6 | it('correctly sets values in maps', () => { 7 | const obj = { 8 | a: new Map([[new Set(['NaN']), [[1, 'undefined']]]]), 9 | }; 10 | 11 | setDeep(obj, ['a', 0, 0, 0], Number); 12 | setDeep(obj, ['a', 0, 1], entries => new Map(entries)); 13 | setDeep(obj, ['a', 0, 1, 0, 1], () => undefined); 14 | 15 | expect(obj).toEqual({ 16 | a: new Map([[new Set([NaN]), new Map([[1, undefined]])]]), 17 | }); 18 | }); 19 | 20 | it('correctly sets values in sets', () => { 21 | const obj = { 22 | a: new Set([10, new Set(['NaN'])]), 23 | }; 24 | 25 | setDeep(obj, ['a', 1, 0], Number); 26 | 27 | expect(obj).toEqual({ 28 | a: new Set([10, new Set([NaN])]), 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/accessDeep.ts: -------------------------------------------------------------------------------- 1 | import { isMap, isArray, isPlainObject, isSet } from './is.js'; 2 | import { includes } from './util.js'; 3 | 4 | const getNthKey = (value: Map | Set, n: number): any => { 5 | if (n > value.size) throw new Error('index out of bounds'); 6 | const keys = value.keys(); 7 | while (n > 0) { 8 | keys.next(); 9 | n--; 10 | } 11 | 12 | return keys.next().value; 13 | }; 14 | 15 | function validatePath(path: (string | number)[]) { 16 | if (includes(path, '__proto__')) { 17 | throw new Error('__proto__ is not allowed as a property'); 18 | } 19 | if (includes(path, 'prototype')) { 20 | throw new Error('prototype is not allowed as a property'); 21 | } 22 | if (includes(path, 'constructor')) { 23 | throw new Error('constructor is not allowed as a property'); 24 | } 25 | } 26 | 27 | export const getDeep = (object: object, path: (string | number)[]): object => { 28 | validatePath(path); 29 | 30 | for (let i = 0; i < path.length; i++) { 31 | const key = path[i]; 32 | if (isSet(object)) { 33 | object = getNthKey(object, +key); 34 | } else if (isMap(object)) { 35 | const row = +key; 36 | const type = +path[++i] === 0 ? 'key' : 'value'; 37 | 38 | const keyOfRow = getNthKey(object, row); 39 | switch (type) { 40 | case 'key': 41 | object = keyOfRow; 42 | break; 43 | case 'value': 44 | object = object.get(keyOfRow); 45 | break; 46 | } 47 | } else { 48 | object = (object as any)[key]; 49 | } 50 | } 51 | 52 | return object; 53 | }; 54 | 55 | export const setDeep = ( 56 | object: any, 57 | path: (string | number)[], 58 | mapper: (v: any) => any 59 | ): any => { 60 | validatePath(path); 61 | 62 | if (path.length === 0) { 63 | return mapper(object); 64 | } 65 | 66 | let parent = object; 67 | 68 | for (let i = 0; i < path.length - 1; i++) { 69 | const key = path[i]; 70 | 71 | if (isArray(parent)) { 72 | const index = +key; 73 | parent = parent[index]; 74 | } else if (isPlainObject(parent)) { 75 | parent = parent[key]; 76 | } else if (isSet(parent)) { 77 | const row = +key; 78 | parent = getNthKey(parent, row); 79 | } else if (isMap(parent)) { 80 | const isEnd = i === path.length - 2; 81 | if (isEnd) { 82 | break; 83 | } 84 | 85 | const row = +key; 86 | const type = +path[++i] === 0 ? 'key' : 'value'; 87 | 88 | const keyOfRow = getNthKey(parent, row); 89 | switch (type) { 90 | case 'key': 91 | parent = keyOfRow; 92 | break; 93 | case 'value': 94 | parent = parent.get(keyOfRow); 95 | break; 96 | } 97 | } 98 | } 99 | 100 | const lastKey = path[path.length - 1]; 101 | 102 | if (isArray(parent)) { 103 | parent[+lastKey] = mapper(parent[+lastKey]); 104 | } else if (isPlainObject(parent)) { 105 | parent[lastKey] = mapper(parent[lastKey]); 106 | } 107 | 108 | if (isSet(parent)) { 109 | const oldValue = getNthKey(parent, +lastKey); 110 | const newValue = mapper(oldValue); 111 | if (oldValue !== newValue) { 112 | parent.delete(oldValue); 113 | parent.add(newValue); 114 | } 115 | } 116 | 117 | if (isMap(parent)) { 118 | const row = +path[path.length - 2]; 119 | const keyToRow = getNthKey(parent, row); 120 | 121 | const type = +lastKey === 0 ? 'key' : 'value'; 122 | switch (type) { 123 | case 'key': { 124 | const newKey = mapper(keyToRow); 125 | parent.set(newKey, parent.get(keyToRow)); 126 | 127 | if (newKey !== keyToRow) { 128 | parent.delete(keyToRow); 129 | } 130 | break; 131 | } 132 | 133 | case 'value': { 134 | parent.set(keyToRow, mapper(parent.get(keyToRow))); 135 | break; 136 | } 137 | } 138 | } 139 | 140 | return object; 141 | }; 142 | -------------------------------------------------------------------------------- /src/class-registry.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from './registry.js'; 2 | import { Class } from './types.js'; 3 | 4 | export interface RegisterOptions { 5 | identifier?: string; 6 | allowProps?: string[]; 7 | } 8 | 9 | export class ClassRegistry extends Registry { 10 | constructor() { 11 | super(c => c.name); 12 | } 13 | 14 | private classToAllowedProps = new Map(); 15 | 16 | register(value: Class, options?: string | RegisterOptions): void { 17 | if (typeof options === 'object') { 18 | if (options.allowProps) { 19 | this.classToAllowedProps.set(value, options.allowProps); 20 | } 21 | 22 | super.register(value, options.identifier); 23 | } else { 24 | super.register(value, options); 25 | } 26 | } 27 | 28 | getAllowedProps(value: Class): string[] | undefined { 29 | return this.classToAllowedProps.get(value); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/custom-transformer-registry.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from './types.js'; 2 | import { find } from './util.js'; 3 | 4 | export interface CustomTransfomer { 5 | name: string; 6 | isApplicable: (v: any) => v is I; 7 | serialize: (v: I) => O; 8 | deserialize: (v: O) => I; 9 | } 10 | 11 | export class CustomTransformerRegistry { 12 | private transfomers: Record> = {}; 13 | 14 | register(transformer: CustomTransfomer) { 15 | this.transfomers[transformer.name] = transformer; 16 | } 17 | 18 | findApplicable(v: T) { 19 | return find(this.transfomers, transformer => 20 | transformer.isApplicable(v) 21 | ) as CustomTransfomer | undefined; 22 | } 23 | 24 | findByName(name: string) { 25 | return this.transfomers[name]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/double-indexed-kv.ts: -------------------------------------------------------------------------------- 1 | export class DoubleIndexedKV { 2 | keyToValue = new Map(); 3 | valueToKey = new Map(); 4 | 5 | set(key: K, value: V) { 6 | this.keyToValue.set(key, value); 7 | this.valueToKey.set(value, key); 8 | } 9 | 10 | getByKey(key: K): V | undefined { 11 | return this.keyToValue.get(key); 12 | } 13 | 14 | getByValue(value: V): K | undefined { 15 | return this.valueToKey.get(value); 16 | } 17 | 18 | clear() { 19 | this.keyToValue.clear(); 20 | this.valueToKey.clear(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable es5/no-for-of */ 2 | /* eslint-disable es5/no-es6-methods */ 3 | 4 | import * as fs from 'fs'; 5 | 6 | import SuperJSON from './index.js'; 7 | import { JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; 8 | import { 9 | isArray, 10 | isMap, 11 | isPlainObject, 12 | isPrimitive, 13 | isSet, 14 | isTypedArray, 15 | } from './is.js'; 16 | 17 | import { ObjectID } from 'mongodb'; 18 | import { Decimal } from 'decimal.js'; 19 | 20 | import { describe, it, expect, test } from 'vitest'; 21 | 22 | const isNode10 = process.version.indexOf('v10') === 0; 23 | 24 | describe('stringify & parse', () => { 25 | const cases: Record< 26 | string, 27 | { 28 | input: (() => SuperJSONValue) | SuperJSONValue; 29 | output: JSONValue | ((v: JSONValue) => void); 30 | outputAnnotations?: SuperJSONResult['meta']; 31 | customExpectations?: (value: any) => void; 32 | skipOnNode10?: boolean; 33 | dontExpectEquality?: boolean; 34 | only?: boolean; 35 | } 36 | > = { 37 | 'works for objects': { 38 | input: { 39 | a: { 1: 5, 2: { 3: 'c' } }, 40 | b: null, 41 | }, 42 | output: { 43 | a: { 1: 5, 2: { 3: 'c' } }, 44 | b: null, 45 | }, 46 | }, 47 | 48 | 'special case: objects with array-like keys': { 49 | input: { 50 | a: { 0: 3, 1: 5, 2: { 3: 'c' } }, 51 | b: null, 52 | }, 53 | output: { 54 | a: { 0: 3, 1: 5, 2: { 3: 'c' } }, 55 | b: null, 56 | }, 57 | }, 58 | 59 | 'works for arrays': { 60 | input: { 61 | a: [1, undefined, 2], 62 | }, 63 | output: { 64 | a: [1, null, 2], 65 | }, 66 | outputAnnotations: { 67 | values: { 68 | 'a.1': ['undefined'], 69 | }, 70 | }, 71 | }, 72 | 73 | 'works for Sets': { 74 | input: { 75 | a: new Set([1, undefined, 2]), 76 | }, 77 | output: { 78 | a: [1, null, 2], 79 | }, 80 | outputAnnotations: { 81 | values: { 82 | a: ['set', { 1: ['undefined'] }], 83 | }, 84 | }, 85 | }, 86 | 87 | 'works for top-level Sets': { 88 | input: new Set([1, undefined, 2]), 89 | output: [1, null, 2], 90 | outputAnnotations: { 91 | values: ['set', { 1: ['undefined'] }], 92 | }, 93 | }, 94 | 95 | 'works for Maps': { 96 | input: { 97 | a: new Map([ 98 | [1, 'a'], 99 | [NaN, 'b'], 100 | ]), 101 | b: new Map([['2', 'b']]), 102 | d: new Map([[true, 'true key']]), 103 | }, 104 | 105 | output: { 106 | a: [ 107 | [1, 'a'], 108 | ['NaN', 'b'], 109 | ], 110 | b: [['2', 'b']], 111 | d: [[true, 'true key']], 112 | }, 113 | 114 | outputAnnotations: { 115 | values: { 116 | a: ['map', { '1.0': ['number'] }], 117 | b: ['map'], 118 | d: ['map'], 119 | }, 120 | }, 121 | }, 122 | 123 | 'preserves object identity': { 124 | input: () => { 125 | const a = { id: 'a' }; 126 | const b = { id: 'b' }; 127 | return { 128 | options: [a, b], 129 | selected: a, 130 | }; 131 | }, 132 | output: { 133 | options: [{ id: 'a' }, { id: 'b' }], 134 | selected: { id: 'a' }, 135 | }, 136 | outputAnnotations: { 137 | referentialEqualities: { 138 | selected: ['options.0'], 139 | }, 140 | }, 141 | customExpectations: output => { 142 | expect(output.selected).toBe(output.options[0]); 143 | }, 144 | }, 145 | 146 | 'works for paths containing dots': { 147 | input: { 148 | 'a.1': { 149 | b: new Set([1, 2]), 150 | }, 151 | }, 152 | output: { 153 | 'a.1': { 154 | b: [1, 2], 155 | }, 156 | }, 157 | outputAnnotations: { 158 | values: { 159 | 'a\\.1.b': ['set'], 160 | }, 161 | }, 162 | }, 163 | 164 | 'works for paths containing backslashes': { 165 | input: { 166 | 'a\\.1': { 167 | b: new Set([1, 2]), 168 | }, 169 | }, 170 | output: { 171 | 'a\\.1': { 172 | b: [1, 2], 173 | }, 174 | }, 175 | outputAnnotations: { 176 | values: { 177 | 'a\\\\.1.b': ['set'], 178 | }, 179 | }, 180 | }, 181 | 182 | 'works for dates': { 183 | input: { 184 | meeting: { 185 | date: new Date(2020, 1, 1), 186 | }, 187 | }, 188 | output: { 189 | meeting: { 190 | date: new Date(2020, 1, 1).toISOString(), 191 | }, 192 | }, 193 | outputAnnotations: { 194 | values: { 195 | 'meeting.date': ['Date'], 196 | }, 197 | }, 198 | }, 199 | 200 | 'works for Errors': { 201 | input: { 202 | e: new Error('epic fail'), 203 | }, 204 | output: ({ e }: any) => { 205 | expect(e.name).toBe('Error'); 206 | expect(e.message).toBe('epic fail'); 207 | }, 208 | outputAnnotations: { 209 | values: { 210 | e: ['Error'], 211 | }, 212 | }, 213 | }, 214 | 215 | 'works for regex': { 216 | input: { 217 | a: /hello/g, 218 | }, 219 | output: { 220 | a: '/hello/g', 221 | }, 222 | outputAnnotations: { 223 | values: { 224 | a: ['regexp'], 225 | }, 226 | }, 227 | }, 228 | 229 | 'works for Infinity': { 230 | input: { 231 | a: Number.POSITIVE_INFINITY, 232 | }, 233 | output: { 234 | a: 'Infinity', 235 | }, 236 | outputAnnotations: { 237 | values: { 238 | a: ['number'], 239 | }, 240 | }, 241 | }, 242 | 243 | 'works for -Infinity': { 244 | input: { 245 | a: Number.NEGATIVE_INFINITY, 246 | }, 247 | output: { 248 | a: '-Infinity', 249 | }, 250 | outputAnnotations: { 251 | values: { 252 | a: ['number'], 253 | }, 254 | }, 255 | }, 256 | 257 | 'works for NaN': { 258 | input: { 259 | a: NaN, 260 | }, 261 | output: { 262 | a: 'NaN', 263 | }, 264 | outputAnnotations: { 265 | values: { 266 | a: ['number'], 267 | }, 268 | }, 269 | }, 270 | 271 | 'works for bigint': { 272 | input: { 273 | a: BigInt('1021312312412312312313'), 274 | }, 275 | output: { 276 | a: '1021312312412312312313', 277 | }, 278 | outputAnnotations: { 279 | values: { 280 | a: ['bigint'], 281 | }, 282 | }, 283 | }, 284 | 285 | 'works for unknown': { 286 | input: () => { 287 | type Freak = { 288 | name: string; 289 | age: unknown; 290 | }; 291 | 292 | const person: Freak = { 293 | name: '@ftonato', 294 | age: 1, 295 | }; 296 | 297 | return person; 298 | }, 299 | output: { 300 | name: '@ftonato', 301 | age: 1, 302 | }, 303 | outputAnnotations: undefined, 304 | }, 305 | 306 | 'works for self-referencing objects': { 307 | input: () => { 308 | const a = { role: 'parent', children: [] as any[] }; 309 | const b = { role: 'child', parents: [a] }; 310 | a.children.push(b); 311 | return a; 312 | }, 313 | output: { 314 | role: 'parent', 315 | children: [ 316 | { 317 | role: 'child', 318 | parents: [null], 319 | }, 320 | ], 321 | }, 322 | outputAnnotations: { 323 | referentialEqualities: [['children.0.parents.0']], 324 | }, 325 | }, 326 | 327 | 'works for Maps with two keys that serialize to the same string but have a different reference': { 328 | input: new Map([ 329 | [/a/g, 'foo'], 330 | [/a/g, 'bar'], 331 | ]), 332 | output: [ 333 | ['/a/g', 'foo'], 334 | ['/a/g', 'bar'], 335 | ], 336 | outputAnnotations: { 337 | values: [ 338 | 'map', 339 | { 340 | '0.0': ['regexp'], 341 | '1.0': ['regexp'], 342 | }, 343 | ], 344 | }, 345 | }, 346 | 347 | "works for Maps with a key that's referentially equal to another field": { 348 | input: () => { 349 | const robbyBubble = { id: 5 }; 350 | const highscores = new Map([[robbyBubble, 5000]]); 351 | return { 352 | highscores, 353 | topScorer: robbyBubble, 354 | } as any; 355 | }, 356 | output: { 357 | highscores: [[{ id: 5 }, 5000]], 358 | topScorer: { id: 5 }, 359 | }, 360 | outputAnnotations: { 361 | values: { 362 | highscores: ['map'], 363 | }, 364 | referentialEqualities: { 365 | topScorer: ['highscores.0.0'], 366 | }, 367 | }, 368 | }, 369 | 370 | 'works for referentially equal maps': { 371 | input: () => { 372 | const map = new Map([[1, 1]]); 373 | return { 374 | a: map, 375 | b: map, 376 | }; 377 | }, 378 | output: { 379 | a: [[1, 1]], 380 | b: [[1, 1]], 381 | }, 382 | outputAnnotations: { 383 | values: { 384 | a: ['map'], 385 | b: ['map'], 386 | }, 387 | referentialEqualities: { 388 | a: ['b'], 389 | }, 390 | }, 391 | customExpectations: value => { 392 | expect(value.a).toBe(value.b); 393 | }, 394 | }, 395 | 396 | 'works for maps with non-uniform keys': { 397 | input: { 398 | map: new Map([ 399 | [1, 1], 400 | ['1', 1], 401 | ]), 402 | }, 403 | output: { 404 | map: [ 405 | [1, 1], 406 | ['1', 1], 407 | ], 408 | }, 409 | outputAnnotations: { 410 | values: { 411 | map: ['map'], 412 | }, 413 | }, 414 | }, 415 | 416 | 'works for referentially equal values inside a set': { 417 | input: () => { 418 | const user = { id: 2 }; 419 | return { 420 | users: new Set([user]), 421 | userOfTheMonth: user, 422 | }; 423 | }, 424 | output: { 425 | users: [{ id: 2 }], 426 | userOfTheMonth: { id: 2 }, 427 | }, 428 | outputAnnotations: { 429 | values: { 430 | users: ['set'], 431 | }, 432 | referentialEqualities: { 433 | userOfTheMonth: ['users.0'], 434 | }, 435 | }, 436 | customExpectations: value => { 437 | expect(value.users.values().next().value).toBe(value.userOfTheMonth); 438 | }, 439 | }, 440 | 441 | 'works for referentially equal values in different maps and sets': { 442 | input: () => { 443 | const user = { id: 2 }; 444 | 445 | return { 446 | workspaces: new Map([ 447 | [1, { users: new Set([user]) }], 448 | [2, { users: new Set([user]) }], 449 | ]), 450 | }; 451 | }, 452 | output: { 453 | workspaces: [ 454 | [1, { users: [{ id: 2 }] }], 455 | [2, { users: [{ id: 2 }] }], 456 | ], 457 | }, 458 | outputAnnotations: { 459 | values: { 460 | workspaces: [ 461 | 'map', 462 | { 463 | '0.1.users': ['set'], 464 | '1.1.users': ['set'], 465 | }, 466 | ], 467 | }, 468 | referentialEqualities: { 469 | 'workspaces.0.1.users.0': ['workspaces.1.1.users.0'], 470 | }, 471 | }, 472 | }, 473 | 474 | 'works for symbols': { 475 | skipOnNode10: true, 476 | input: () => { 477 | const parent = Symbol('Parent'); 478 | const child = Symbol('Child'); 479 | SuperJSON.registerSymbol(parent, '1'); 480 | SuperJSON.registerSymbol(child, '2'); 481 | 482 | const a = { role: parent }; 483 | const b = { role: child }; 484 | 485 | return { a, b }; 486 | }, 487 | output: { 488 | a: { role: 'Parent' }, 489 | b: { role: 'Child' }, 490 | }, 491 | outputAnnotations: { 492 | values: { 493 | 'a.role': [['symbol', '1']], 494 | 'b.role': [['symbol', '2']], 495 | }, 496 | }, 497 | }, 498 | 499 | 'works for custom transformers': { 500 | input: () => { 501 | SuperJSON.registerCustom( 502 | { 503 | isApplicable: (v): v is ObjectID => v instanceof ObjectID, 504 | serialize: v => v.toHexString(), 505 | deserialize: v => new ObjectID(v), 506 | }, 507 | 'objectid' 508 | ); 509 | 510 | return { 511 | a: new ObjectID('5f7887f4f0b172093e89f126'), 512 | }; 513 | }, 514 | output: { 515 | a: '5f7887f4f0b172093e89f126', 516 | }, 517 | outputAnnotations: { 518 | values: { 519 | a: [['custom', 'objectid']], 520 | }, 521 | }, 522 | }, 523 | 524 | 'works for Decimal.js': { 525 | input: () => { 526 | SuperJSON.registerCustom( 527 | { 528 | isApplicable: (v): v is Decimal => Decimal.isDecimal(v), 529 | serialize: v => v.toJSON(), 530 | deserialize: v => new Decimal(v), 531 | }, 532 | 'decimal.js' 533 | ); 534 | 535 | return { 536 | a: new Decimal('100.1'), 537 | }; 538 | }, 539 | output: { 540 | a: '100.1', 541 | }, 542 | outputAnnotations: { 543 | values: { 544 | a: [['custom', 'decimal.js']], 545 | }, 546 | }, 547 | }, 548 | 549 | 'issue #58': { 550 | skipOnNode10: true, 551 | input: () => { 552 | const cool = Symbol('cool'); 553 | SuperJSON.registerSymbol(cool); 554 | return { 555 | q: [ 556 | 9, 557 | { 558 | henlo: undefined, 559 | yee: new Date(2020, 1, 1), 560 | yee2: new Date(2020, 1, 1), 561 | foo1: new Date(2020, 1, 1), 562 | z: cool, 563 | }, 564 | ], 565 | }; 566 | }, 567 | output: { 568 | q: [ 569 | 9, 570 | { 571 | henlo: null, 572 | yee: new Date(2020, 1, 1).toISOString(), 573 | yee2: new Date(2020, 1, 1).toISOString(), 574 | foo1: new Date(2020, 1, 1).toISOString(), 575 | z: 'cool', 576 | }, 577 | ], 578 | }, 579 | outputAnnotations: { 580 | values: { 581 | 'q.1.henlo': ['undefined'], 582 | 'q.1.yee': ['Date'], 583 | 'q.1.yee2': ['Date'], 584 | 'q.1.foo1': ['Date'], 585 | 'q.1.z': [['symbol', 'cool']], 586 | }, 587 | }, 588 | }, 589 | 590 | 'works with custom allowedProps': { 591 | input: () => { 592 | class User { 593 | constructor(public username: string, public password: string) {} 594 | } 595 | SuperJSON.registerClass(User, { allowProps: ['username'] }); 596 | return new User('bongocat', 'supersecurepassword'); 597 | }, 598 | output: { 599 | username: 'bongocat', 600 | }, 601 | outputAnnotations: { 602 | values: [['class', 'User']], 603 | }, 604 | customExpectations(value) { 605 | expect(value.password).toBeUndefined(); 606 | expect(value.username).toBe('bongocat'); 607 | }, 608 | dontExpectEquality: true, 609 | }, 610 | 611 | 'works with typed arrays': { 612 | input: { 613 | a: new Int8Array([1, 2]), 614 | b: new Uint8ClampedArray(3), 615 | }, 616 | output: { 617 | a: [1, 2], 618 | b: [0, 0, 0], 619 | }, 620 | outputAnnotations: { 621 | values: { 622 | a: [['typed-array', 'Int8Array']], 623 | b: [['typed-array', 'Uint8ClampedArray']], 624 | }, 625 | }, 626 | }, 627 | 628 | 'works for undefined, issue #48': { 629 | input: undefined, 630 | output: null, 631 | outputAnnotations: { values: ['undefined'] }, 632 | }, 633 | 634 | 'regression #109: nested classes': { 635 | input: () => { 636 | class Pet { 637 | constructor(private name: string) {} 638 | 639 | woof() { 640 | return this.name; 641 | } 642 | } 643 | 644 | class User { 645 | constructor(public pet: Pet) {} 646 | } 647 | 648 | SuperJSON.registerClass(Pet); 649 | SuperJSON.registerClass(User); 650 | 651 | const pet = new Pet('Rover'); 652 | const user = new User(pet); 653 | 654 | return user; 655 | }, 656 | output: { 657 | pet: { 658 | name: 'Rover', 659 | }, 660 | }, 661 | outputAnnotations: { 662 | values: [ 663 | ['class', 'User'], 664 | { 665 | pet: [['class', 'Pet']], 666 | }, 667 | ], 668 | }, 669 | customExpectations(value) { 670 | expect(value.pet.woof()).toEqual('Rover'); 671 | }, 672 | }, 673 | 'works with URL': { 674 | input: { 675 | a: new URL('https://example.com/'), 676 | b: new URL('https://github.com/blitz-js/superjson'), 677 | }, 678 | output: { 679 | a: 'https://example.com/', 680 | b: 'https://github.com/blitz-js/superjson', 681 | }, 682 | outputAnnotations: { 683 | values: { 684 | a: ['URL'], 685 | b: ['URL'], 686 | }, 687 | }, 688 | }, 689 | }; 690 | 691 | function deepFreeze(object: any, alreadySeenObjects = new Set()) { 692 | if (isPrimitive(object)) { 693 | return; 694 | } 695 | 696 | if (isTypedArray(object)) { 697 | return; 698 | } 699 | 700 | if (alreadySeenObjects.has(object)) { 701 | return; 702 | } else { 703 | alreadySeenObjects.add(object); 704 | } 705 | 706 | if (isPlainObject(object)) { 707 | Object.values(object).forEach(o => deepFreeze(o, alreadySeenObjects)); 708 | } 709 | 710 | if (isSet(object)) { 711 | object.forEach(o => deepFreeze(o, alreadySeenObjects)); 712 | } 713 | 714 | if (isArray(object)) { 715 | object.forEach(o => deepFreeze(o, alreadySeenObjects)); 716 | } 717 | 718 | if (isMap(object)) { 719 | object.forEach((value, key) => { 720 | deepFreeze(key, alreadySeenObjects); 721 | deepFreeze(value, alreadySeenObjects); 722 | }); 723 | } 724 | 725 | Object.freeze(object); 726 | } 727 | 728 | for (const [ 729 | testName, 730 | { 731 | input, 732 | output: expectedOutput, 733 | outputAnnotations: expectedOutputAnnotations, 734 | customExpectations, 735 | skipOnNode10, 736 | dontExpectEquality, 737 | only, 738 | }, 739 | ] of Object.entries(cases)) { 740 | let testFunc = test; 741 | 742 | if (skipOnNode10 && isNode10) { 743 | testFunc = test.skip; 744 | } 745 | 746 | if (only) { 747 | testFunc = test.only; 748 | } 749 | 750 | testFunc(testName, () => { 751 | const inputValue = typeof input === 'function' ? input() : input; 752 | 753 | // let's make sure SuperJSON doesn't mutate our input! 754 | deepFreeze(inputValue); 755 | const { json, meta } = SuperJSON.serialize(inputValue); 756 | 757 | if (typeof expectedOutput === 'function') { 758 | expectedOutput(json); 759 | } else { 760 | expect(json).toEqual(expectedOutput); 761 | } 762 | expect(meta).toEqual(expectedOutputAnnotations); 763 | 764 | const untransformed = SuperJSON.deserialize( 765 | JSON.parse(JSON.stringify({ json, meta })) 766 | ); 767 | if (!dontExpectEquality) { 768 | expect(untransformed).toEqual(inputValue); 769 | } 770 | customExpectations?.(untransformed); 771 | }); 772 | } 773 | 774 | describe('when serializing custom class instances', () => { 775 | it('revives them to their original class', () => { 776 | class Train { 777 | constructor( 778 | private topSpeed: number, 779 | private color: 'red' | 'blue' | 'yellow', 780 | private brand: string 781 | ) {} 782 | 783 | public brag() { 784 | return `I'm a ${this.brand} in freakin' ${this.color} and I go ${this.topSpeed} km/h, isn't that bonkers?`; 785 | } 786 | } 787 | 788 | SuperJSON.registerClass(Train); 789 | 790 | const { json, meta } = SuperJSON.serialize({ 791 | s7: new Train(100, 'yellow', 'Bombardier') as any, 792 | }); 793 | 794 | expect(json).toEqual({ 795 | s7: { 796 | topSpeed: 100, 797 | color: 'yellow', 798 | brand: 'Bombardier', 799 | }, 800 | }); 801 | 802 | expect(meta).toEqual({ 803 | values: { 804 | s7: [['class', 'Train']], 805 | }, 806 | }); 807 | 808 | const deserialized: any = SuperJSON.deserialize( 809 | JSON.parse(JSON.stringify({ json, meta })) 810 | ); 811 | expect(deserialized.s7).toBeInstanceOf(Train); 812 | expect(typeof deserialized.s7.brag()).toBe('string'); 813 | }); 814 | 815 | describe('with accessor attributes', () => { 816 | it('works', () => { 817 | class Currency { 818 | constructor(private valueInUsd: number) {} 819 | 820 | // @ts-ignore 821 | get inUSD() { 822 | return this.valueInUsd; 823 | } 824 | } 825 | 826 | SuperJSON.registerClass(Currency); 827 | 828 | const { json, meta } = SuperJSON.serialize({ 829 | price: new Currency(100) as any, 830 | }); 831 | 832 | expect(json).toEqual({ 833 | price: { 834 | valueInUsd: 100, 835 | }, 836 | }); 837 | 838 | const result: any = SuperJSON.parse(JSON.stringify({ json, meta })); 839 | 840 | const price: Currency = result.price; 841 | 842 | expect(price.inUSD).toBe(100); 843 | }); 844 | }); 845 | }); 846 | 847 | describe('when given a non-SuperJSON object', () => { 848 | it.todo('has undefined behaviour'); 849 | }); 850 | 851 | test('regression #65: BigInt on Safari v13', () => { 852 | const oldBigInt = global.BigInt; 853 | // @ts-ignore 854 | delete global.BigInt; 855 | 856 | const input = { 857 | a: oldBigInt('1000'), 858 | }; 859 | 860 | const superJSONed = SuperJSON.serialize(input); 861 | expect(superJSONed).toEqual({ 862 | json: { 863 | a: '1000', 864 | }, 865 | meta: { 866 | values: { 867 | a: ['bigint'], 868 | }, 869 | }, 870 | }); 871 | 872 | const deserialised = SuperJSON.deserialize( 873 | JSON.parse(JSON.stringify(superJSONed)) 874 | ); 875 | expect(deserialised).toEqual({ 876 | a: '1000', 877 | }); 878 | 879 | global.BigInt = oldBigInt; 880 | }); 881 | 882 | test('regression #80: Custom error serialisation isnt overriden', () => { 883 | class CustomError extends Error { 884 | constructor(public readonly customProperty: number) { 885 | super("I'm a custom error"); 886 | // eslint-disable-next-line es5/no-es6-static-methods 887 | Object.setPrototypeOf(this, CustomError.prototype); 888 | } 889 | } 890 | 891 | expect(new CustomError(10)).toBeInstanceOf(CustomError); 892 | 893 | SuperJSON.registerClass(CustomError); 894 | 895 | const { error } = SuperJSON.deserialize( 896 | SuperJSON.serialize({ 897 | error: new CustomError(10), 898 | }) 899 | ) as any; 900 | 901 | expect(error).toBeInstanceOf(CustomError); 902 | expect(error.customProperty).toEqual(10); 903 | }); 904 | }); 905 | 906 | describe('allowErrorProps(...) (#91)', () => { 907 | it('works with simple prop values', () => { 908 | const errorWithAdditionalProps: Error & any = new Error( 909 | 'I have additional props 😄' 910 | ); 911 | errorWithAdditionalProps.code = 'P2002'; 912 | errorWithAdditionalProps.meta = '👾'; 913 | 914 | // same as allowErrorProps("code", "meta") 915 | SuperJSON.allowErrorProps('code'); 916 | SuperJSON.allowErrorProps('meta'); 917 | 918 | const errorAfterTransition: any = SuperJSON.parse( 919 | SuperJSON.stringify(errorWithAdditionalProps) 920 | ); 921 | 922 | expect(errorAfterTransition).toBeInstanceOf(Error); 923 | expect(errorAfterTransition.message).toEqual('I have additional props 😄'); 924 | expect(errorAfterTransition.code).toEqual('P2002'); 925 | expect(errorAfterTransition.meta).toEqual('👾'); 926 | }); 927 | 928 | it.skip('works with complex prop values', () => { 929 | const errorWithAdditionalProps: any = new Error(); 930 | errorWithAdditionalProps.map = new Map(); 931 | 932 | SuperJSON.allowErrorProps('map'); 933 | 934 | const errorAfterTransition: any = SuperJSON.parse( 935 | SuperJSON.stringify(errorWithAdditionalProps) 936 | ); 937 | 938 | expect(errorAfterTransition.map).toEqual(undefined); 939 | 940 | expect(errorAfterTransition.map).toBeInstanceOf(Map); 941 | }); 942 | }); 943 | 944 | test('regression #83: negative zero', () => { 945 | const input = -0; 946 | 947 | const stringified = SuperJSON.stringify(input); 948 | expect(stringified).toMatchInlineSnapshot( 949 | `"{\\"json\\":\\"-0\\",\\"meta\\":{\\"values\\":[\\"number\\"]}}"` 950 | ); 951 | 952 | const parsed: number = SuperJSON.parse(stringified); 953 | 954 | expect(1 / parsed).toBe(-Infinity); 955 | }); 956 | 957 | test('regression https://github.com/blitz-js/babel-plugin-superjson-next/issues/63: Nested BigInt', () => { 958 | const serialized = SuperJSON.serialize({ 959 | topics: [ 960 | { 961 | post_count: BigInt('22'), 962 | }, 963 | ], 964 | }); 965 | 966 | expect(() => JSON.stringify(serialized)).not.toThrow(); 967 | 968 | expect(typeof (serialized.json as any).topics[0].post_count).toBe('string'); 969 | expect(serialized.json).toEqual({ 970 | topics: [ 971 | { 972 | post_count: '22', 973 | }, 974 | ], 975 | }); 976 | 977 | SuperJSON.deserialize(serialized); 978 | expect(typeof (serialized.json as any).topics[0].post_count).toBe('string'); 979 | }); 980 | 981 | test('performance regression', () => { 982 | const data: any[] = []; 983 | for (let i = 0; i < 100; i++) { 984 | let nested1 = []; 985 | let nested2 = []; 986 | for (let j = 0; j < 10; j++) { 987 | nested1[j] = { 988 | createdAt: new Date(), 989 | updatedAt: new Date(), 990 | innerNested: { 991 | createdAt: new Date(), 992 | updatedAt: new Date(), 993 | }, 994 | }; 995 | nested2[j] = { 996 | createdAt: new Date(), 997 | updatedAt: new Date(), 998 | innerNested: { 999 | createdAt: new Date(), 1000 | updatedAt: new Date(), 1001 | }, 1002 | }; 1003 | } 1004 | const object = { 1005 | createdAt: new Date(), 1006 | updatedAt: new Date(), 1007 | nested1, 1008 | nested2, 1009 | }; 1010 | data.push(object); 1011 | } 1012 | 1013 | const t1 = Date.now(); 1014 | SuperJSON.serialize(data); 1015 | const t2 = Date.now(); 1016 | const duration = t2 - t1; 1017 | expect(duration).toBeLessThan(700); 1018 | }); 1019 | 1020 | test('regression #95: no undefined', () => { 1021 | const input: unknown[] = []; 1022 | 1023 | const out = SuperJSON.serialize(input); 1024 | expect(out).not.toHaveProperty('meta'); 1025 | 1026 | const parsed: number = SuperJSON.deserialize(out); 1027 | 1028 | expect(parsed).toEqual(input); 1029 | }); 1030 | 1031 | test('regression #108: Error#stack should not be included by default', () => { 1032 | const input = new Error("Beep boop, you don't wanna see me. I'm an error!"); 1033 | expect(input).toHaveProperty('stack'); 1034 | 1035 | const { stack: thatShouldBeUndefined } = SuperJSON.parse( 1036 | SuperJSON.stringify(input) 1037 | ) as any; 1038 | expect(thatShouldBeUndefined).toBeUndefined(); 1039 | 1040 | SuperJSON.allowErrorProps('stack'); 1041 | const { stack: thatShouldExist } = SuperJSON.parse( 1042 | SuperJSON.stringify(input) 1043 | ) as any; 1044 | expect(thatShouldExist).toEqual(input.stack); 1045 | }); 1046 | 1047 | test('regression: `Object.create(null)` / object without prototype', () => { 1048 | const input: Record = Object.create(null); 1049 | input.date = new Date(); 1050 | 1051 | const stringified = SuperJSON.stringify(input); 1052 | const parsed: any = SuperJSON.parse(stringified); 1053 | 1054 | expect(parsed.date).toBeInstanceOf(Date); 1055 | }); 1056 | 1057 | test.each(['__proto__', 'prototype', 'constructor'])( 1058 | 'serialize prototype pollution: %s', 1059 | forbidden => { 1060 | expect(() => { 1061 | SuperJSON.serialize({ 1062 | [forbidden]: 1, 1063 | }); 1064 | }).toThrowError(/This is a prototype pollution risk/); 1065 | } 1066 | ); 1067 | 1068 | test('prototype pollution - __proto__', () => { 1069 | expect(() => { 1070 | SuperJSON.parse( 1071 | JSON.stringify({ 1072 | json: { 1073 | myValue: 1337, 1074 | }, 1075 | meta: { 1076 | referentialEqualities: { 1077 | myValue: ['__proto__.x'], 1078 | }, 1079 | }, 1080 | }) 1081 | ); 1082 | }).toThrowErrorMatchingInlineSnapshot( 1083 | `"__proto__ is not allowed as a property"` 1084 | ); 1085 | expect((Object.prototype as any).x).toBeUndefined(); 1086 | }); 1087 | 1088 | test('prototype pollution - prototype', () => { 1089 | expect(() => { 1090 | SuperJSON.parse( 1091 | JSON.stringify({ 1092 | json: { 1093 | myValue: 1337, 1094 | }, 1095 | meta: { 1096 | referentialEqualities: { 1097 | myValue: ['prototype.x'], 1098 | }, 1099 | }, 1100 | }) 1101 | ); 1102 | }).toThrowErrorMatchingInlineSnapshot( 1103 | `"prototype is not allowed as a property"` 1104 | ); 1105 | }); 1106 | 1107 | test('prototype pollution - constructor', () => { 1108 | expect(() => { 1109 | SuperJSON.parse( 1110 | JSON.stringify({ 1111 | json: { 1112 | myValue: 1337, 1113 | }, 1114 | meta: { 1115 | referentialEqualities: { 1116 | myValue: ['constructor.prototype.x'], 1117 | }, 1118 | }, 1119 | }) 1120 | ); 1121 | }).toThrowErrorMatchingInlineSnapshot( 1122 | `"prototype is not allowed as a property"` 1123 | ); 1124 | 1125 | expect((Object.prototype as any).x).toBeUndefined(); 1126 | }); 1127 | 1128 | test('superjson instances are independent of one another', () => { 1129 | class Car {} 1130 | const s1 = new SuperJSON(); 1131 | s1.registerClass(Car); 1132 | 1133 | const s2 = new SuperJSON(); 1134 | 1135 | const value = { 1136 | car: new Car(), 1137 | }; 1138 | 1139 | const res1 = s1.serialize(value); 1140 | expect(res1.meta?.values).toEqual({ car: [['class', 'Car']] }); 1141 | const res2 = s2.serialize(value); 1142 | expect(res2.json).toEqual(value); 1143 | }); 1144 | 1145 | test('regression #245: superjson referential equalities only use the top-most parent node', () => { 1146 | type Node = { 1147 | children: Node[]; 1148 | }; 1149 | const root: Node = { 1150 | children: [], 1151 | }; 1152 | const input = { 1153 | a: root, 1154 | b: root, 1155 | }; 1156 | const res = SuperJSON.serialize(input); 1157 | 1158 | expect(res.meta?.referentialEqualities).toHaveProperty(['a']); 1159 | 1160 | // saying that a.children is equal to b.children is redundant since its already know that a === b 1161 | expect(res.meta?.referentialEqualities).not.toHaveProperty(['a.children']); 1162 | expect(res.meta).toMatchInlineSnapshot(` 1163 | { 1164 | "referentialEqualities": { 1165 | "a": [ 1166 | "b", 1167 | ], 1168 | }, 1169 | } 1170 | `); 1171 | 1172 | const parsed = SuperJSON.deserialize(res); 1173 | expect(parsed).toEqual(input); 1174 | }); 1175 | 1176 | test('dedupe=true', () => { 1177 | const instance = new SuperJSON({ 1178 | dedupe: true, 1179 | }); 1180 | 1181 | type Node = { 1182 | children: Node[]; 1183 | }; 1184 | const root: Node = { 1185 | children: [], 1186 | }; 1187 | const input = { 1188 | a: root, 1189 | b: root, 1190 | }; 1191 | const output = instance.serialize(input); 1192 | 1193 | const json = output.json as any; 1194 | 1195 | expect(json.a); 1196 | 1197 | // This has already been seen and should be deduped 1198 | expect(json.b).toBeNull(); 1199 | 1200 | expect(json).toMatchInlineSnapshot(` 1201 | { 1202 | "a": { 1203 | "children": [], 1204 | }, 1205 | "b": null, 1206 | } 1207 | `); 1208 | 1209 | expect(instance.deserialize(output)).toEqual(input); 1210 | }); 1211 | 1212 | test('dedupe=true on a large complicated schema', () => { 1213 | const content = fs.readFileSync(__dirname + '/non-deduped-cal.json', 'utf-8'); 1214 | const parsed = JSON.parse(content); 1215 | 1216 | const deserialized = SuperJSON.deserialize(parsed); 1217 | 1218 | const nondeduped = new SuperJSON({}); 1219 | 1220 | const deduped = new SuperJSON({ 1221 | dedupe: true, 1222 | }); 1223 | 1224 | const nondedupedOut = nondeduped.deserialize( 1225 | nondeduped.serialize(deserialized) 1226 | ); 1227 | const dedupedOut = deduped.deserialize(deduped.serialize(deserialized)); 1228 | 1229 | expect(nondedupedOut).toEqual(deserialized); 1230 | expect(dedupedOut).toEqual(deserialized); 1231 | }); 1232 | 1233 | test('doesnt iterate to keys that dont exist', () => { 1234 | const robbyBubble = { id: 5 }; 1235 | const highscores = new Map([[robbyBubble, 5000]]); 1236 | const objectWithReferentialEquality = { highscores, topScorer: robbyBubble }; 1237 | const res = SuperJSON.serialize(objectWithReferentialEquality); 1238 | 1239 | expect(res.meta.referentialEqualities.topScorer).toEqual(['highscores.0.0']); 1240 | res.meta.referentialEqualities.topScorer = ['highscores.99999.0']; 1241 | 1242 | expect(() => SuperJSON.deserialize(res)).toThrowError('index out of bounds'); 1243 | }); 1244 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Class, JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; 2 | import { ClassRegistry, RegisterOptions } from './class-registry.js'; 3 | import { Registry } from './registry.js'; 4 | import { 5 | CustomTransfomer, 6 | CustomTransformerRegistry, 7 | } from './custom-transformer-registry.js'; 8 | import { 9 | applyReferentialEqualityAnnotations, 10 | applyValueAnnotations, 11 | generateReferentialEqualityAnnotations, 12 | walker, 13 | } from './plainer.js'; 14 | import { copy } from 'copy-anything'; 15 | 16 | export default class SuperJSON { 17 | /** 18 | * If true, SuperJSON will make sure only one instance of referentially equal objects are serialized and the rest are replaced with `null`. 19 | */ 20 | private readonly dedupe: boolean; 21 | 22 | /** 23 | * @param dedupeReferentialEqualities If true, SuperJSON will make sure only one instance of referentially equal objects are serialized and the rest are replaced with `null`. 24 | */ 25 | constructor({ 26 | dedupe = false, 27 | }: { 28 | dedupe?: boolean; 29 | } = {}) { 30 | this.dedupe = dedupe; 31 | } 32 | 33 | serialize(object: SuperJSONValue): SuperJSONResult { 34 | const identities = new Map(); 35 | const output = walker(object, identities, this, this.dedupe); 36 | const res: SuperJSONResult = { 37 | json: output.transformedValue, 38 | }; 39 | 40 | if (output.annotations) { 41 | res.meta = { 42 | ...res.meta, 43 | values: output.annotations, 44 | }; 45 | } 46 | 47 | const equalityAnnotations = generateReferentialEqualityAnnotations( 48 | identities, 49 | this.dedupe 50 | ); 51 | if (equalityAnnotations) { 52 | res.meta = { 53 | ...res.meta, 54 | referentialEqualities: equalityAnnotations, 55 | }; 56 | } 57 | 58 | return res; 59 | } 60 | 61 | deserialize(payload: SuperJSONResult): T { 62 | const { json, meta } = payload; 63 | 64 | let result: T = copy(json) as any; 65 | 66 | if (meta?.values) { 67 | result = applyValueAnnotations(result, meta.values, this); 68 | } 69 | 70 | if (meta?.referentialEqualities) { 71 | result = applyReferentialEqualityAnnotations( 72 | result, 73 | meta.referentialEqualities 74 | ); 75 | } 76 | 77 | return result; 78 | } 79 | 80 | stringify(object: SuperJSONValue): string { 81 | return JSON.stringify(this.serialize(object)); 82 | } 83 | 84 | parse(string: string): T { 85 | return this.deserialize(JSON.parse(string)); 86 | } 87 | 88 | readonly classRegistry = new ClassRegistry(); 89 | registerClass(v: Class, options?: RegisterOptions | string) { 90 | this.classRegistry.register(v, options); 91 | } 92 | 93 | readonly symbolRegistry = new Registry(s => s.description ?? ''); 94 | registerSymbol(v: Symbol, identifier?: string) { 95 | this.symbolRegistry.register(v, identifier); 96 | } 97 | 98 | readonly customTransformerRegistry = new CustomTransformerRegistry(); 99 | registerCustom( 100 | transformer: Omit, 'name'>, 101 | name: string 102 | ) { 103 | this.customTransformerRegistry.register({ 104 | name, 105 | ...transformer, 106 | }); 107 | } 108 | 109 | readonly allowedErrorProps: string[] = []; 110 | allowErrorProps(...props: string[]) { 111 | this.allowedErrorProps.push(...props); 112 | } 113 | 114 | private static defaultInstance = new SuperJSON(); 115 | static serialize = SuperJSON.defaultInstance.serialize.bind( 116 | SuperJSON.defaultInstance 117 | ); 118 | static deserialize = SuperJSON.defaultInstance.deserialize.bind( 119 | SuperJSON.defaultInstance 120 | ); 121 | static stringify = SuperJSON.defaultInstance.stringify.bind( 122 | SuperJSON.defaultInstance 123 | ); 124 | static parse = SuperJSON.defaultInstance.parse.bind( 125 | SuperJSON.defaultInstance 126 | ); 127 | static registerClass = SuperJSON.defaultInstance.registerClass.bind( 128 | SuperJSON.defaultInstance 129 | ); 130 | static registerSymbol = SuperJSON.defaultInstance.registerSymbol.bind( 131 | SuperJSON.defaultInstance 132 | ); 133 | static registerCustom = SuperJSON.defaultInstance.registerCustom.bind( 134 | SuperJSON.defaultInstance 135 | ); 136 | static allowErrorProps = SuperJSON.defaultInstance.allowErrorProps.bind( 137 | SuperJSON.defaultInstance 138 | ); 139 | } 140 | 141 | export { SuperJSON, SuperJSONResult }; 142 | 143 | export const serialize = SuperJSON.serialize; 144 | export const deserialize = SuperJSON.deserialize; 145 | 146 | export const stringify = SuperJSON.stringify; 147 | export const parse = SuperJSON.parse; 148 | 149 | export const registerClass = SuperJSON.registerClass; 150 | export const registerCustom = SuperJSON.registerCustom; 151 | export const registerSymbol = SuperJSON.registerSymbol; 152 | export const allowErrorProps = SuperJSON.allowErrorProps; 153 | -------------------------------------------------------------------------------- /src/is.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isArray, 3 | isBoolean, 4 | isDate, 5 | isNull, 6 | isNumber, 7 | isPrimitive, 8 | isRegExp, 9 | isString, 10 | isSymbol, 11 | isUndefined, 12 | isPlainObject, 13 | isTypedArray, 14 | isURL, 15 | } from './is.js'; 16 | 17 | import { test, expect } from 'vitest'; 18 | 19 | test('Basic true tests', () => { 20 | expect(isUndefined(undefined)).toBe(true); 21 | expect(isNull(null)).toBe(true); 22 | 23 | expect(isArray([])).toBe(true); 24 | expect(isArray([])).toBe(true); 25 | expect(isString('')).toBe(true); 26 | expect(isString('_')).toBe(true); 27 | 28 | expect(isBoolean(true)).toBe(true); 29 | expect(isBoolean(false)).toBe(true); 30 | expect(isRegExp(/./)).toBe(true); 31 | expect(isRegExp(/./gi)).toBe(true); 32 | expect(isNumber(0)).toBe(true); 33 | expect(isNumber(1)).toBe(true); 34 | expect(isDate(new Date())).toBe(true); 35 | expect(isSymbol(Symbol())).toBe(true); 36 | expect(isTypedArray(new Uint8Array())).toBe(true); 37 | expect(isURL(new URL('https://example.com'))).toBe(true); 38 | expect(isPlainObject({})).toBe(true); 39 | // eslint-disable-next-line no-new-object 40 | expect(isPlainObject(new Object())).toBe(true); 41 | }); 42 | 43 | test('Basic false tests', () => { 44 | expect(isNumber(NaN)).toBe(false); 45 | expect(isDate(new Date('_'))).toBe(false); 46 | expect(isDate(NaN)).toBe(false); 47 | expect(isUndefined(NaN)).toBe(false); 48 | expect(isNull(NaN)).toBe(false); 49 | 50 | expect(isArray(NaN)).toBe(false); 51 | expect(isString(NaN)).toBe(false); 52 | 53 | expect(isBoolean(NaN)).toBe(false); 54 | expect(isRegExp(NaN)).toBe(false); 55 | expect(isSymbol(NaN)).toBe(false); 56 | 57 | expect(isTypedArray([])).toBe(false); 58 | 59 | expect(isURL('https://example.com')).toBe(false); 60 | 61 | expect(isPlainObject(null)).toBe(false); 62 | expect(isPlainObject([])).toBe(false); 63 | expect(isPlainObject(Object.prototype)).toBe(false); 64 | expect(isPlainObject(Object.create(Array.prototype))).toBe(false); 65 | }); 66 | 67 | test('Primitive tests', () => { 68 | expect(isPrimitive(0)).toBe(true); 69 | expect(isPrimitive('')).toBe(true); 70 | expect(isPrimitive('str')).toBe(true); 71 | expect(isPrimitive(Symbol())).toBe(true); 72 | expect(isPrimitive(true)).toBe(true); 73 | expect(isPrimitive(false)).toBe(true); 74 | expect(isPrimitive(null)).toBe(true); 75 | expect(isPrimitive(undefined)).toBe(true); 76 | 77 | expect(isPrimitive(NaN)).toBe(false); 78 | expect(isPrimitive([])).toBe(false); 79 | expect(isPrimitive([])).toBe(false); 80 | expect(isPrimitive({})).toBe(false); 81 | // eslint-disable-next-line no-new-object 82 | expect(isPrimitive(new Object())).toBe(false); 83 | expect(isPrimitive(new Date())).toBe(false); 84 | expect(isPrimitive(() => {})).toBe(false); 85 | }); 86 | 87 | test('Date exception', () => { 88 | expect(isDate(new Date('_'))).toBe(false); 89 | }); 90 | 91 | test('Regression: null-prototype object', () => { 92 | expect(isPlainObject(Object.create(null))).toBe(true); 93 | expect(isPrimitive(Object.create(null))).toBe(false); 94 | }); 95 | -------------------------------------------------------------------------------- /src/is.ts: -------------------------------------------------------------------------------- 1 | const getType = (payload: any): string => 2 | Object.prototype.toString.call(payload).slice(8, -1); 3 | 4 | export const isUndefined = (payload: any): payload is undefined => 5 | typeof payload === 'undefined'; 6 | 7 | export const isNull = (payload: any): payload is null => payload === null; 8 | 9 | export const isPlainObject = ( 10 | payload: any 11 | ): payload is { [key: string]: any } => { 12 | if (typeof payload !== 'object' || payload === null) return false; 13 | if (payload === Object.prototype) return false; 14 | if (Object.getPrototypeOf(payload) === null) return true; 15 | 16 | return Object.getPrototypeOf(payload) === Object.prototype; 17 | }; 18 | 19 | export const isEmptyObject = (payload: any): payload is {} => 20 | isPlainObject(payload) && Object.keys(payload).length === 0; 21 | 22 | export const isArray = (payload: any): payload is any[] => 23 | Array.isArray(payload); 24 | 25 | export const isString = (payload: any): payload is string => 26 | typeof payload === 'string'; 27 | 28 | export const isNumber = (payload: any): payload is number => 29 | typeof payload === 'number' && !isNaN(payload); 30 | 31 | export const isBoolean = (payload: any): payload is boolean => 32 | typeof payload === 'boolean'; 33 | 34 | export const isRegExp = (payload: any): payload is RegExp => 35 | payload instanceof RegExp; 36 | 37 | export const isMap = (payload: any): payload is Map => 38 | payload instanceof Map; 39 | 40 | export const isSet = (payload: any): payload is Set => 41 | payload instanceof Set; 42 | 43 | export const isSymbol = (payload: any): payload is symbol => 44 | getType(payload) === 'Symbol'; 45 | 46 | export const isDate = (payload: any): payload is Date => 47 | payload instanceof Date && !isNaN(payload.valueOf()); 48 | 49 | export const isError = (payload: any): payload is Error => 50 | payload instanceof Error; 51 | 52 | export const isNaNValue = (payload: any): payload is typeof NaN => 53 | typeof payload === 'number' && isNaN(payload); 54 | 55 | export const isPrimitive = ( 56 | payload: any 57 | ): payload is boolean | null | undefined | number | string | symbol => 58 | isBoolean(payload) || 59 | isNull(payload) || 60 | isUndefined(payload) || 61 | isNumber(payload) || 62 | isString(payload) || 63 | isSymbol(payload); 64 | 65 | export const isBigint = (payload: any): payload is bigint => 66 | typeof payload === 'bigint'; 67 | 68 | export const isInfinite = (payload: any): payload is number => 69 | payload === Infinity || payload === -Infinity; 70 | 71 | export type TypedArrayConstructor = 72 | | Int8ArrayConstructor 73 | | Uint8ArrayConstructor 74 | | Uint8ClampedArrayConstructor 75 | | Int16ArrayConstructor 76 | | Uint16ArrayConstructor 77 | | Int32ArrayConstructor 78 | | Uint32ArrayConstructor 79 | | Float32ArrayConstructor 80 | | Float64ArrayConstructor; 81 | 82 | export type TypedArray = InstanceType; 83 | 84 | export const isTypedArray = (payload: any): payload is TypedArray => 85 | ArrayBuffer.isView(payload) && !(payload instanceof DataView); 86 | 87 | export const isURL = (payload: any): payload is URL => payload instanceof URL; 88 | -------------------------------------------------------------------------------- /src/pathstringifier.test.ts: -------------------------------------------------------------------------------- 1 | import { parsePath, escapeKey } from './pathstringifier.js'; 2 | 3 | import { test, describe, it, expect } from 'vitest'; 4 | 5 | describe('parsePath', () => { 6 | it.each([ 7 | ['test.a.b', ['test', 'a', 'b']], 8 | ['test\\.a.b', ['test.a', 'b']], 9 | ['test\\\\.a.b', ['test\\.a', 'b']], 10 | ['test\\a.b', ['test\\a', 'b']], 11 | ['test\\\\a.b', ['test\\\\a', 'b']], 12 | ])('parsePath(%p) === %p', (input, expectedOutput) => { 13 | expect(parsePath(input)).toEqual(expectedOutput); 14 | }); 15 | }); 16 | 17 | describe('escapeKey', () => { 18 | test.each([ 19 | ['dontescape', 'dontescape'], 20 | ['escape.me', 'escape\\.me'], 21 | ])('escapeKey(%s) === %s', (input, expectedOutput) => { 22 | expect(escapeKey(input)).toEqual(expectedOutput); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/pathstringifier.ts: -------------------------------------------------------------------------------- 1 | export type StringifiedPath = string; 2 | type Path = string[]; 3 | 4 | export const escapeKey = (key: string) => key.replace(/\./g, '\\.'); 5 | 6 | export const stringifyPath = (path: Path): StringifiedPath => 7 | path 8 | .map(String) 9 | .map(escapeKey) 10 | .join('.'); 11 | 12 | export const parsePath = (string: StringifiedPath) => { 13 | const result: string[] = []; 14 | 15 | let segment = ''; 16 | for (let i = 0; i < string.length; i++) { 17 | let char = string.charAt(i); 18 | 19 | const isEscapedDot = char === '\\' && string.charAt(i + 1) === '.'; 20 | if (isEscapedDot) { 21 | segment += '.'; 22 | i++; 23 | continue; 24 | } 25 | 26 | const isEndOfSegment = char === '.'; 27 | if (isEndOfSegment) { 28 | result.push(segment); 29 | segment = ''; 30 | continue; 31 | } 32 | 33 | segment += char; 34 | } 35 | 36 | const lastSegment = segment; 37 | result.push(lastSegment); 38 | 39 | return result; 40 | }; 41 | -------------------------------------------------------------------------------- /src/plainer.spec.ts: -------------------------------------------------------------------------------- 1 | import SuperJSON from './index.js'; 2 | import { walker } from './plainer.js'; 3 | 4 | import { test, expect } from 'vitest'; 5 | 6 | test('walker', () => { 7 | expect( 8 | walker( 9 | { 10 | a: new Map([[NaN, null]]), 11 | b: /test/g, 12 | }, 13 | new Map(), 14 | new SuperJSON(), 15 | false 16 | ) 17 | ).toEqual({ 18 | transformedValue: { 19 | a: [['NaN', null]], 20 | b: '/test/g', 21 | }, 22 | annotations: { 23 | a: [ 24 | 'map', 25 | { 26 | '0.0': ['number'], 27 | }, 28 | ], 29 | b: ['regexp'], 30 | }, 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/plainer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isArray, 3 | isEmptyObject, 4 | isMap, 5 | isPlainObject, 6 | isPrimitive, 7 | isSet, 8 | } from './is.js'; 9 | import { escapeKey, stringifyPath } from './pathstringifier.js'; 10 | import { 11 | isInstanceOfRegisteredClass, 12 | transformValue, 13 | TypeAnnotation, 14 | untransformValue, 15 | } from './transformer.js'; 16 | import { includes, forEach } from './util.js'; 17 | import { parsePath } from './pathstringifier.js'; 18 | import { getDeep, setDeep } from './accessDeep.js'; 19 | import SuperJSON from './index.js'; 20 | 21 | type Tree = InnerNode | Leaf; 22 | type Leaf = [T]; 23 | type InnerNode = [T, Record>]; 24 | 25 | export type MinimisedTree = Tree | Record> | undefined; 26 | 27 | function traverse( 28 | tree: MinimisedTree, 29 | walker: (v: T, path: string[]) => void, 30 | origin: string[] = [] 31 | ): void { 32 | if (!tree) { 33 | return; 34 | } 35 | 36 | if (!isArray(tree)) { 37 | forEach(tree, (subtree, key) => 38 | traverse(subtree, walker, [...origin, ...parsePath(key)]) 39 | ); 40 | return; 41 | } 42 | 43 | const [nodeValue, children] = tree; 44 | if (children) { 45 | forEach(children, (child, key) => { 46 | traverse(child, walker, [...origin, ...parsePath(key)]); 47 | }); 48 | } 49 | 50 | walker(nodeValue, origin); 51 | } 52 | 53 | export function applyValueAnnotations( 54 | plain: any, 55 | annotations: MinimisedTree, 56 | superJson: SuperJSON 57 | ) { 58 | traverse(annotations, (type, path) => { 59 | plain = setDeep(plain, path, v => untransformValue(v, type, superJson)); 60 | }); 61 | 62 | return plain; 63 | } 64 | 65 | export function applyReferentialEqualityAnnotations( 66 | plain: any, 67 | annotations: ReferentialEqualityAnnotations 68 | ) { 69 | function apply(identicalPaths: string[], path: string) { 70 | const object = getDeep(plain, parsePath(path)); 71 | 72 | identicalPaths.map(parsePath).forEach(identicalObjectPath => { 73 | plain = setDeep(plain, identicalObjectPath, () => object); 74 | }); 75 | } 76 | 77 | if (isArray(annotations)) { 78 | const [root, other] = annotations; 79 | root.forEach(identicalPath => { 80 | plain = setDeep(plain, parsePath(identicalPath), () => plain); 81 | }); 82 | 83 | if (other) { 84 | forEach(other, apply); 85 | } 86 | } else { 87 | forEach(annotations, apply); 88 | } 89 | 90 | return plain; 91 | } 92 | 93 | const isDeep = (object: any, superJson: SuperJSON): boolean => 94 | isPlainObject(object) || 95 | isArray(object) || 96 | isMap(object) || 97 | isSet(object) || 98 | isInstanceOfRegisteredClass(object, superJson); 99 | 100 | function addIdentity(object: any, path: any[], identities: Map) { 101 | const existingSet = identities.get(object); 102 | 103 | if (existingSet) { 104 | existingSet.push(path); 105 | } else { 106 | identities.set(object, [path]); 107 | } 108 | } 109 | 110 | interface Result { 111 | transformedValue: any; 112 | annotations?: MinimisedTree; 113 | } 114 | 115 | export type ReferentialEqualityAnnotations = 116 | | Record 117 | | [string[]] 118 | | [string[], Record]; 119 | 120 | export function generateReferentialEqualityAnnotations( 121 | identitites: Map, 122 | dedupe: boolean 123 | ): ReferentialEqualityAnnotations | undefined { 124 | const result: Record = {}; 125 | let rootEqualityPaths: string[] | undefined = undefined; 126 | 127 | identitites.forEach(paths => { 128 | if (paths.length <= 1) { 129 | return; 130 | } 131 | 132 | // if we're not deduping, all of these objects continue existing. 133 | // putting the shortest path first makes it easier to parse for humans 134 | // if we're deduping though, only the first entry will still exist, so we can't do this optimisation. 135 | if (!dedupe) { 136 | paths = paths 137 | .map(path => path.map(String)) 138 | .sort((a, b) => a.length - b.length); 139 | } 140 | 141 | const [representativePath, ...identicalPaths] = paths; 142 | 143 | if (representativePath.length === 0) { 144 | rootEqualityPaths = identicalPaths.map(stringifyPath); 145 | } else { 146 | result[stringifyPath(representativePath)] = identicalPaths.map( 147 | stringifyPath 148 | ); 149 | } 150 | }); 151 | 152 | if (rootEqualityPaths) { 153 | if (isEmptyObject(result)) { 154 | return [rootEqualityPaths]; 155 | } else { 156 | return [rootEqualityPaths, result]; 157 | } 158 | } else { 159 | return isEmptyObject(result) ? undefined : result; 160 | } 161 | } 162 | 163 | export const walker = ( 164 | object: any, 165 | identities: Map, 166 | superJson: SuperJSON, 167 | dedupe: boolean, 168 | path: any[] = [], 169 | objectsInThisPath: any[] = [], 170 | seenObjects = new Map() 171 | ): Result => { 172 | const primitive = isPrimitive(object); 173 | 174 | if (!primitive) { 175 | addIdentity(object, path, identities); 176 | 177 | const seen = seenObjects.get(object); 178 | if (seen) { 179 | // short-circuit result if we've seen this object before 180 | return dedupe 181 | ? { 182 | transformedValue: null, 183 | } 184 | : seen; 185 | } 186 | } 187 | 188 | if (!isDeep(object, superJson)) { 189 | const transformed = transformValue(object, superJson); 190 | 191 | const result: Result = transformed 192 | ? { 193 | transformedValue: transformed.value, 194 | annotations: [transformed.type], 195 | } 196 | : { 197 | transformedValue: object, 198 | }; 199 | if (!primitive) { 200 | seenObjects.set(object, result); 201 | } 202 | return result; 203 | } 204 | 205 | if (includes(objectsInThisPath, object)) { 206 | // prevent circular references 207 | return { 208 | transformedValue: null, 209 | }; 210 | } 211 | 212 | const transformationResult = transformValue(object, superJson); 213 | const transformed = transformationResult?.value ?? object; 214 | 215 | const transformedValue: any = isArray(transformed) ? [] : {}; 216 | const innerAnnotations: Record> = {}; 217 | 218 | forEach(transformed, (value, index) => { 219 | if ( 220 | index === '__proto__' || 221 | index === 'constructor' || 222 | index === 'prototype' 223 | ) { 224 | throw new Error( 225 | `Detected property ${index}. This is a prototype pollution risk, please remove it from your object.` 226 | ); 227 | } 228 | 229 | const recursiveResult = walker( 230 | value, 231 | identities, 232 | superJson, 233 | dedupe, 234 | [...path, index], 235 | [...objectsInThisPath, object], 236 | seenObjects 237 | ); 238 | 239 | transformedValue[index] = recursiveResult.transformedValue; 240 | 241 | if (isArray(recursiveResult.annotations)) { 242 | innerAnnotations[index] = recursiveResult.annotations; 243 | } else if (isPlainObject(recursiveResult.annotations)) { 244 | forEach(recursiveResult.annotations, (tree, key) => { 245 | innerAnnotations[escapeKey(index) + '.' + key] = tree; 246 | }); 247 | } 248 | }); 249 | 250 | const result: Result = isEmptyObject(innerAnnotations) 251 | ? { 252 | transformedValue, 253 | annotations: !!transformationResult 254 | ? [transformationResult.type] 255 | : undefined, 256 | } 257 | : { 258 | transformedValue, 259 | annotations: !!transformationResult 260 | ? [transformationResult.type, innerAnnotations] 261 | : innerAnnotations, 262 | }; 263 | if (!primitive) { 264 | seenObjects.set(object, result); 265 | } 266 | 267 | return result; 268 | }; 269 | -------------------------------------------------------------------------------- /src/registry.test.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from './registry.js'; 2 | import { Class } from './types.js'; 3 | 4 | import { test, expect } from 'vitest'; 5 | 6 | test('class registry', () => { 7 | const registry = new Registry(c => c.name); 8 | 9 | class Car { 10 | honk() { 11 | console.log('honk'); 12 | } 13 | } 14 | registry.register(Car); 15 | 16 | expect(registry.getValue('Car')).toBe(Car); 17 | expect(registry.getIdentifier(Car)).toBe('Car'); 18 | 19 | expect(() => registry.register(Car)).not.toThrow(); 20 | 21 | registry.register(class Car {}, 'car2'); 22 | 23 | expect(registry.getValue('car2')).not.toBeUndefined(); 24 | 25 | registry.clear(); 26 | 27 | expect(registry.getValue('car1')).toBeUndefined(); 28 | }); 29 | -------------------------------------------------------------------------------- /src/registry.ts: -------------------------------------------------------------------------------- 1 | import { DoubleIndexedKV } from './double-indexed-kv.js'; 2 | 3 | export class Registry { 4 | private kv = new DoubleIndexedKV(); 5 | 6 | constructor(private readonly generateIdentifier: (v: T) => string) {} 7 | 8 | register(value: T, identifier?: string): void { 9 | if (this.kv.getByValue(value)) { 10 | return; 11 | } 12 | 13 | if (!identifier) { 14 | identifier = this.generateIdentifier(value); 15 | } 16 | 17 | this.kv.set(identifier, value); 18 | } 19 | 20 | clear(): void { 21 | this.kv.clear(); 22 | } 23 | 24 | getIdentifier(value: T) { 25 | return this.kv.getByValue(value); 26 | } 27 | 28 | getValue(identifier: string) { 29 | return this.kv.getByKey(identifier); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/transformer.test.ts: -------------------------------------------------------------------------------- 1 | import SuperJSON from './index.js'; 2 | 3 | import { test, expect } from 'vitest'; 4 | 5 | test('throws an descriptive error when transforming', () => { 6 | const instance = new SuperJSON(); 7 | class FunnyNumber { 8 | constructor(private number: number) {} 9 | 10 | // @ts-ignore 11 | get theNumber() { 12 | return this.number; 13 | } 14 | } 15 | instance.registerClass(FunnyNumber); 16 | expect(() => 17 | instance.deserialize({ 18 | json: instance.serialize({ 19 | number: new FunnyNumber(2137), 20 | }).json, 21 | meta: { 22 | values: [['class', 'NotRegistered']], 23 | }, 24 | }) 25 | ).toThrowError( 26 | `Trying to deserialize unknown class 'NotRegistered' - check https://github.com/blitz-js/superjson/issues/116#issuecomment-773996564` 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/transformer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isBigint, 3 | isDate, 4 | isInfinite, 5 | isMap, 6 | isNaNValue, 7 | isRegExp, 8 | isSet, 9 | isUndefined, 10 | isSymbol, 11 | isArray, 12 | isError, 13 | isTypedArray, 14 | TypedArrayConstructor, 15 | isURL, 16 | } from './is.js'; 17 | import { findArr } from './util.js'; 18 | import SuperJSON from './index.js'; 19 | 20 | export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; 21 | 22 | type LeafTypeAnnotation = 23 | | PrimitiveTypeAnnotation 24 | | 'regexp' 25 | | 'Date' 26 | | 'Error' 27 | | 'URL'; 28 | 29 | type TypedArrayAnnotation = ['typed-array', string]; 30 | type ClassTypeAnnotation = ['class', string]; 31 | type SymbolTypeAnnotation = ['symbol', string]; 32 | type CustomTypeAnnotation = ['custom', string]; 33 | 34 | type SimpleTypeAnnotation = LeafTypeAnnotation | 'map' | 'set'; 35 | 36 | type CompositeTypeAnnotation = 37 | | TypedArrayAnnotation 38 | | ClassTypeAnnotation 39 | | SymbolTypeAnnotation 40 | | CustomTypeAnnotation; 41 | 42 | export type TypeAnnotation = SimpleTypeAnnotation | CompositeTypeAnnotation; 43 | 44 | function simpleTransformation( 45 | isApplicable: (v: any, superJson: SuperJSON) => v is I, 46 | annotation: A, 47 | transform: (v: I, superJson: SuperJSON) => O, 48 | untransform: (v: O, superJson: SuperJSON) => I 49 | ) { 50 | return { 51 | isApplicable, 52 | annotation, 53 | transform, 54 | untransform, 55 | }; 56 | } 57 | 58 | const simpleRules = [ 59 | simpleTransformation( 60 | isUndefined, 61 | 'undefined', 62 | () => null, 63 | () => undefined 64 | ), 65 | simpleTransformation( 66 | isBigint, 67 | 'bigint', 68 | v => v.toString(), 69 | v => { 70 | if (typeof BigInt !== 'undefined') { 71 | return BigInt(v); 72 | } 73 | 74 | console.error('Please add a BigInt polyfill.'); 75 | 76 | return v as any; 77 | } 78 | ), 79 | simpleTransformation( 80 | isDate, 81 | 'Date', 82 | v => v.toISOString(), 83 | v => new Date(v) 84 | ), 85 | 86 | simpleTransformation( 87 | isError, 88 | 'Error', 89 | (v, superJson) => { 90 | const baseError: any = { 91 | name: v.name, 92 | message: v.message, 93 | }; 94 | 95 | superJson.allowedErrorProps.forEach(prop => { 96 | baseError[prop] = (v as any)[prop]; 97 | }); 98 | 99 | return baseError; 100 | }, 101 | (v, superJson) => { 102 | const e = new Error(v.message); 103 | e.name = v.name; 104 | e.stack = v.stack; 105 | 106 | superJson.allowedErrorProps.forEach(prop => { 107 | (e as any)[prop] = v[prop]; 108 | }); 109 | 110 | return e; 111 | } 112 | ), 113 | 114 | simpleTransformation( 115 | isRegExp, 116 | 'regexp', 117 | v => '' + v, 118 | regex => { 119 | const body = regex.slice(1, regex.lastIndexOf('/')); 120 | const flags = regex.slice(regex.lastIndexOf('/') + 1); 121 | return new RegExp(body, flags); 122 | } 123 | ), 124 | 125 | simpleTransformation( 126 | isSet, 127 | 'set', 128 | // (sets only exist in es6+) 129 | // eslint-disable-next-line es5/no-es6-methods 130 | v => [...v.values()], 131 | v => new Set(v) 132 | ), 133 | simpleTransformation( 134 | isMap, 135 | 'map', 136 | v => [...v.entries()], 137 | v => new Map(v) 138 | ), 139 | 140 | simpleTransformation( 141 | (v): v is number => isNaNValue(v) || isInfinite(v), 142 | 'number', 143 | v => { 144 | if (isNaNValue(v)) { 145 | return 'NaN'; 146 | } 147 | 148 | if (v > 0) { 149 | return 'Infinity'; 150 | } else { 151 | return '-Infinity'; 152 | } 153 | }, 154 | Number 155 | ), 156 | 157 | simpleTransformation( 158 | (v): v is number => v === 0 && 1 / v === -Infinity, 159 | 'number', 160 | () => { 161 | return '-0'; 162 | }, 163 | Number 164 | ), 165 | 166 | simpleTransformation( 167 | isURL, 168 | 'URL', 169 | v => v.toString(), 170 | v => new URL(v) 171 | ), 172 | ]; 173 | 174 | function compositeTransformation( 175 | isApplicable: (v: any, superJson: SuperJSON) => v is I, 176 | annotation: (v: I, superJson: SuperJSON) => A, 177 | transform: (v: I, superJson: SuperJSON) => O, 178 | untransform: (v: O, a: A, superJson: SuperJSON) => I 179 | ) { 180 | return { 181 | isApplicable, 182 | annotation, 183 | transform, 184 | untransform, 185 | }; 186 | } 187 | 188 | const symbolRule = compositeTransformation( 189 | (s, superJson): s is Symbol => { 190 | if (isSymbol(s)) { 191 | const isRegistered = !!superJson.symbolRegistry.getIdentifier(s); 192 | return isRegistered; 193 | } 194 | return false; 195 | }, 196 | (s, superJson) => { 197 | const identifier = superJson.symbolRegistry.getIdentifier(s); 198 | return ['symbol', identifier!]; 199 | }, 200 | v => v.description, 201 | (_, a, superJson) => { 202 | const value = superJson.symbolRegistry.getValue(a[1]); 203 | if (!value) { 204 | throw new Error('Trying to deserialize unknown symbol'); 205 | } 206 | return value; 207 | } 208 | ); 209 | 210 | const constructorToName = [ 211 | Int8Array, 212 | Uint8Array, 213 | Int16Array, 214 | Uint16Array, 215 | Int32Array, 216 | Uint32Array, 217 | Float32Array, 218 | Float64Array, 219 | Uint8ClampedArray, 220 | ].reduce>((obj, ctor) => { 221 | obj[ctor.name] = ctor; 222 | return obj; 223 | }, {}); 224 | 225 | const typedArrayRule = compositeTransformation( 226 | isTypedArray, 227 | v => ['typed-array', v.constructor.name], 228 | v => [...v], 229 | (v, a) => { 230 | const ctor = constructorToName[a[1]]; 231 | 232 | if (!ctor) { 233 | throw new Error('Trying to deserialize unknown typed array'); 234 | } 235 | 236 | return new ctor(v); 237 | } 238 | ); 239 | 240 | export function isInstanceOfRegisteredClass( 241 | potentialClass: any, 242 | superJson: SuperJSON 243 | ): potentialClass is any { 244 | if (potentialClass?.constructor) { 245 | const isRegistered = !!superJson.classRegistry.getIdentifier( 246 | potentialClass.constructor 247 | ); 248 | return isRegistered; 249 | } 250 | return false; 251 | } 252 | 253 | const classRule = compositeTransformation( 254 | isInstanceOfRegisteredClass, 255 | (clazz, superJson) => { 256 | const identifier = superJson.classRegistry.getIdentifier(clazz.constructor); 257 | return ['class', identifier!]; 258 | }, 259 | (clazz, superJson) => { 260 | const allowedProps = superJson.classRegistry.getAllowedProps( 261 | clazz.constructor 262 | ); 263 | if (!allowedProps) { 264 | return { ...clazz }; 265 | } 266 | 267 | const result: any = {}; 268 | allowedProps.forEach(prop => { 269 | result[prop] = clazz[prop]; 270 | }); 271 | return result; 272 | }, 273 | (v, a, superJson) => { 274 | const clazz = superJson.classRegistry.getValue(a[1]); 275 | 276 | if (!clazz) { 277 | throw new Error( 278 | `Trying to deserialize unknown class '${a[1]}' - check https://github.com/blitz-js/superjson/issues/116#issuecomment-773996564` 279 | ); 280 | } 281 | 282 | return Object.assign(Object.create(clazz.prototype), v); 283 | } 284 | ); 285 | 286 | const customRule = compositeTransformation( 287 | (value, superJson): value is any => { 288 | return !!superJson.customTransformerRegistry.findApplicable(value); 289 | }, 290 | (value, superJson) => { 291 | const transformer = superJson.customTransformerRegistry.findApplicable( 292 | value 293 | )!; 294 | return ['custom', transformer.name]; 295 | }, 296 | (value, superJson) => { 297 | const transformer = superJson.customTransformerRegistry.findApplicable( 298 | value 299 | )!; 300 | return transformer.serialize(value); 301 | }, 302 | (v, a, superJson) => { 303 | const transformer = superJson.customTransformerRegistry.findByName(a[1]); 304 | if (!transformer) { 305 | throw new Error('Trying to deserialize unknown custom value'); 306 | } 307 | return transformer.deserialize(v); 308 | } 309 | ); 310 | 311 | const compositeRules = [classRule, symbolRule, customRule, typedArrayRule]; 312 | 313 | export const transformValue = ( 314 | value: any, 315 | superJson: SuperJSON 316 | ): { value: any; type: TypeAnnotation } | undefined => { 317 | const applicableCompositeRule = findArr(compositeRules, rule => 318 | rule.isApplicable(value, superJson) 319 | ); 320 | if (applicableCompositeRule) { 321 | return { 322 | value: applicableCompositeRule.transform(value as never, superJson), 323 | type: applicableCompositeRule.annotation(value, superJson), 324 | }; 325 | } 326 | 327 | const applicableSimpleRule = findArr(simpleRules, rule => 328 | rule.isApplicable(value, superJson) 329 | ); 330 | 331 | if (applicableSimpleRule) { 332 | return { 333 | value: applicableSimpleRule.transform(value as never, superJson), 334 | type: applicableSimpleRule.annotation, 335 | }; 336 | } 337 | 338 | return undefined; 339 | }; 340 | 341 | const simpleRulesByAnnotation: Record = {}; 342 | simpleRules.forEach(rule => { 343 | simpleRulesByAnnotation[rule.annotation] = rule; 344 | }); 345 | 346 | export const untransformValue = ( 347 | json: any, 348 | type: TypeAnnotation, 349 | superJson: SuperJSON 350 | ) => { 351 | if (isArray(type)) { 352 | switch (type[0]) { 353 | case 'symbol': 354 | return symbolRule.untransform(json, type, superJson); 355 | case 'class': 356 | return classRule.untransform(json, type, superJson); 357 | case 'custom': 358 | return customRule.untransform(json, type, superJson); 359 | case 'typed-array': 360 | return typedArrayRule.untransform(json, type, superJson); 361 | default: 362 | throw new Error('Unknown transformation: ' + type); 363 | } 364 | } else { 365 | const transformation = simpleRulesByAnnotation[type]; 366 | if (!transformation) { 367 | throw new Error('Unknown transformation: ' + type); 368 | } 369 | 370 | return transformation.untransform(json as never, superJson); 371 | } 372 | }; 373 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { TypeAnnotation } from './transformer.js'; 2 | import { MinimisedTree, ReferentialEqualityAnnotations } from './plainer.js'; 3 | 4 | export type Class = { new (...args: any[]): any }; 5 | 6 | export type PrimitiveJSONValue = string | number | boolean | undefined | null; 7 | 8 | export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject; 9 | 10 | export interface JSONArray extends Array {} 11 | 12 | export interface JSONObject { 13 | [key: string]: JSONValue; 14 | } 15 | 16 | type ClassInstance = any; 17 | 18 | export type SerializableJSONValue = 19 | | Symbol 20 | | Set 21 | | Map 22 | | undefined 23 | | bigint 24 | | Date 25 | | ClassInstance 26 | | RegExp; 27 | 28 | export type SuperJSONValue = 29 | | JSONValue 30 | | SerializableJSONValue 31 | | SuperJSONArray 32 | | SuperJSONObject; 33 | 34 | export interface SuperJSONArray extends Array {} 35 | 36 | export interface SuperJSONObject { 37 | [key: string]: SuperJSONValue; 38 | } 39 | 40 | export interface SuperJSONResult { 41 | json: JSONValue; 42 | meta?: { 43 | values?: MinimisedTree; 44 | referentialEqualities?: ReferentialEqualityAnnotations; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | function valuesOfObj(record: Record): T[] { 2 | if ('values' in Object) { 3 | // eslint-disable-next-line es5/no-es6-methods 4 | return Object.values(record); 5 | } 6 | 7 | const values: T[] = []; 8 | 9 | // eslint-disable-next-line no-restricted-syntax 10 | for (const key in record) { 11 | if (record.hasOwnProperty(key)) { 12 | values.push(record[key]); 13 | } 14 | } 15 | 16 | return values; 17 | } 18 | 19 | export function find( 20 | record: Record, 21 | predicate: (v: T) => boolean 22 | ): T | undefined { 23 | const values = valuesOfObj(record); 24 | if ('find' in values) { 25 | // eslint-disable-next-line es5/no-es6-methods 26 | return values.find(predicate); 27 | } 28 | 29 | const valuesNotNever = values as T[]; 30 | 31 | for (let i = 0; i < valuesNotNever.length; i++) { 32 | const value = valuesNotNever[i]; 33 | if (predicate(value)) { 34 | return value; 35 | } 36 | } 37 | 38 | return undefined; 39 | } 40 | 41 | export function forEach( 42 | record: Record, 43 | run: (v: T, key: string) => void 44 | ) { 45 | Object.entries(record).forEach(([key, value]) => run(value, key)); 46 | } 47 | 48 | export function includes(arr: T[], value: T) { 49 | return arr.indexOf(value) !== -1; 50 | } 51 | 52 | export function findArr( 53 | record: T[], 54 | predicate: (v: T) => boolean 55 | ): T | undefined { 56 | for (let i = 0; i < record.length; i++) { 57 | const value = record[i]; 58 | if (predicate(value)) { 59 | return value; 60 | } 61 | } 62 | 63 | return undefined; 64 | } 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 4 | "compilerOptions": { 5 | "target": "ES2020", 6 | "module": "ES6", 7 | "moduleResolution": "node", 8 | "lib": ["esnext"], 9 | "importHelpers": false, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "rootDir": "./src", 13 | "outDir": "./dist", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "esModuleInterop": true, 20 | "downlevelIteration": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------