diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..44c2507 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: [main, v2] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5fcd7e8..0000000 --- a/.travis.yml +++ /dev/null @@ -1,4 +0,0 @@ -language: node_js -node_js: - - 0.8 - - '0.10' \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index e2fc148..b05b62a 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ # LICENCE The MIT License (MIT) -Copyright (c) 2013 Rodrigo González, Sapienlab +Copyright (c) 2013 Rodrigo González, SASUD Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/Makefile b/Makefile deleted file mode 100644 index d581e5d..0000000 --- a/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -TESTS = test/*.js - -test: - @./node_modules/.bin/mocha \ - $(TESTS) -R spec - -.PHONY: test bench \ No newline at end of file diff --git a/README.md b/README.md index 900a0ae..e0d8dc4 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,136 @@ -[![Build Status](https://secure.travis-ci.org/sapienlab/jsonpack.png)](http://travis-ci.org/sapienlab/jsonpack) -# jsonpack icon for jsonpack compressor +# jsonpack -A compression algorithm for JSON +[![Test](https://github.com/rgcl/jsonpack/actions/workflows/test.yml/badge.svg)](https://github.com/rgcl/jsonpack/actions/workflows/test.yml) +[![npm](https://img.shields.io/npm/v/jsonpack)](https://www.npmjs.com/package/jsonpack) -## Introduction +A URL-safe JSON serializer. Produces compact ASCII output usable directly in URLs and `localStorage` — no base64, no binary, no extra encoding step. -jsonpack is a JavaScript program to pack and unpack JSON data. +## Installation + +```bash +npm install jsonpack +``` -It can compress to 55% of original size if the data has a recursive structure, example -[Earthquake GeoJSON](http://earthquake.usgs.gov/earthquakes/feed/geojson/2.5/month) or -[Twitter API](http://search.twitter.com/search.json?q=Twitter%20API&result_type=mixed). +## Usage -This lib works in both Node.js and browsers (older browsers missing [ES5's JSON.stringify](http://caniuse.com/json) support will need a [shim](http://bestiejs.github.io/json3/)). +**CommonJS** +```js +const { pack, unpack } = require('jsonpack'); +``` -**Quick example** -```javascript -// big JSON -var json = {...} +**ESM** +```js +import { pack, unpack } from 'jsonpack'; +``` -// pack the big JSON -var packed = jsonpack.pack(json); +**Example** +```js +const { pack, unpack } = require('jsonpack'); -// do stuff... +const data = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', geometry: { type: 'Point', coordinates: [-73.98, 40.74] }, properties: { name: 'A' } }, + { type: 'Feature', geometry: { type: 'Point', coordinates: [-73.99, 40.75] }, properties: { name: 'B' } }, + // ... hundreds more + ] +}; -// And then unpack the packed -var json = jsonpack.unpack(packed); +const packed = pack(data); +// repeated keys like "type", "Feature", "geometry", "Point", "coordinates" +// are stored once in a dictionary and referenced by index + +const restored = unpack(packed); ``` -## Installation +## API -**jsonpack** can be installed via [cpm][cpm], [volo][volo] or [npm][npm], or simply [downloaded][download]. +### `pack(json, options?)` -Via cpm: +Serializes a JSON value into a compact URL-safe string. -```bash -$ cpm install jsonpack -``` +- `json` — any JSON-serializable value, or a JSON string +- `options.verbose` — log each step to console (default: `false`) +- `options.debug` — return internal representation instead of string (default: `false`) -Via volo: +`Date` objects are preserved: `unpack(pack(date))` returns a `Date` instance, not a string. -```bash -$ volo add sapienlab/jsonpack -``` +Returns a `string`. -Via npm: +### `unpack(packed, options?)` -```bash -$ npm install jsonpack -``` +Restores the original value from a packed string. -## API +- `packed` — string produced by `pack()` +- `options.verbose` — log each step to console (default: `false`) -### Attributes - -#### jsonpack.JSON -A object that implements the JSON.parse() and JSON.stringify() members. -By default is the native JSON implemented in ECMAscript 5. - -### Members - -#### jsonpack.pack(json, options) -Retrieve a packed representation of the json - -** Parameters ** - -* json {Object|string}: A valid JSON Object or their string representation -* parameters {[Object]}: A optional object - * verbose (devault is false): If is true, print a log message to the console at each step of packing - * example: `jsonpack.pack(json, { verbose: true });` packs with verbose only - * debug {[boolean=false]}: If is true, return a object with the internal representation of the - parser dictionary and the AST - * example: `jsonpack.pack(json, { debug: true });` packs with debug only - -** Returns:** - -* string: the packed string representation of the data -* object: if parameters.debug is true - -##### Examples - -* Example 1: Node.js - -```javascript -// Example in node.js, read a file with JSON content and save another file -// with the packed representation of that JSON -var jsonpack = require('jsonpack/main'), - fs = require('fs'); - -// read a file called myBigJSON.json and execute with -// jsonContent as the content of the file -fs.readFile('../data/bigData.json', 'utf8', function(error, jsonContent) { - - // packed now is a string with the packed version of jsonContent - var packed = jsonpack.pack(jsonContent); - - // save the packed in a file - fs.writeFile('../data/packed.txt', packed); - -}); -``` +Returns the original value. -* Example 2: Browser/Node.js with AMD +--- -```javascript -require(['jsonpack', 'text!../data/bigData.json'], function(jsonpack, jsonContent) { +## Why jsonpack - // packed the data - var packed = jsonpack.pack(jsonContent); - - // Do stuff with the packed string - console.log(packed); -}); -``` +The standard way to embed JSON in a URL or `localStorage` is: -* Example 3: Browser - -```html - +```js +encodeURIComponent(JSON.stringify(data)) ``` -#### jsonpack.unpack(packed, options) +It works, but it *expands* your data — `{`, `"`, `:` become `%7B`, `%22`, `%3A`. A typical API response grows to **140–170% of its original size**. -Unpack the data in the *packed* parameter +The alternative with the best compression, [lz-string](https://github.com/pieroxy/lz-string), shrinks data dramatically but decodes **4–6× slower**. -** Parameters ** +jsonpack sits in between: **< 7 KB minified, zero dependencies** (no transitive dependencies either). -* packed {string} : The result of call jsonpack.packed(...) -* options {[Object]}: Optional object - * verbose (default: false) print a log message to the console at each step of packing +### Benchmark -** Return: ** Object, the clone of the original JSON +Measured across 8 real-world datasets (GeoJSON, e-commerce, API responses, deeply nested structures): -##### Examples +![URL-safe JSON serializers: compression vs speed](charts/01-scatter-compression-vs-speed.png) -* Example 1: Node.js +| | encodeURI(JSON) | **jsonpack** | lz-string (URI) | +|---|:---:|:---:|:---:| +| Avg output size | 152% of original | **68% of original** | 39% of original | +| Avg unpack speed | 262 MB/s | **171 MB/s** | 41 MB/s | +| Zero dependencies | ✓ | ✓ | ✓ | +| URL-safe output | ✓ | ✓ | ✓ | -```javascript -// Example in node.js, read a file with packed content and save another file -// with the string representation of the original JSON -var jsonpack = require('jsonpack/main'), - fs = require('fs'); - -// read a file called packedjson and execute with -// packed as the content of the file -fs.readFile('../data/packed.txt', 'utf8', function(error, packed) { - - // data now is a JavaScript Object of the original JSON - var data = jsonpack.unpack(jsonContent); - - // save the JSON in a file. data is a Javascript Object, so must be - // stringifed (and pretty print the JSON with 2 space indents). - fs.writeFile('../data/unpacked.json', JSON.stringify(data, null, 2)); - -}); -``` +#### Compression by dataset -* Example 2: Browser/Node.js with AMD +![Compression ratio by dataset](charts/02-ratio-per-dataset.png) -```javascript -require(['jsonpack', 'text!../data/packed'], function(jsonpack, packed) { +#### Unpack speed by dataset - // unpacked the data - // json now is a clone of the original JSON - var json = jsonpack.unpack(packed); - - // Do stuff with the JavaScript object - console.log(json); -}); +![Unpack speed by dataset](charts/03-unpack-speed-per-dataset.png) -``` +Full benchmark methodology and raw results: [rgcl/jsonpack-benchmark](https://github.com/rgcl/jsonpack-benchmark) -* Example 3: Browser +### When to use jsonpack -```html - -``` +**Use lz-string instead when** output size is the only constraint and decoding speed doesn't matter. -## FAQ -### This library is stable? -Yes, was tested in Node.js, Chrome and Firefox. +**Use `encodeURIComponent` when** the data is small, changes rarely, or you want zero abstraction. -### How to contribute? -I'm not a native English speaker, so create a issue or better a pull request for all of my grammatical errors :) -As well, if you have a code issue or suggestion, create a issue, Thanks! +### How it works -### What about the icon? -The icon is a generic (LGPL) icon by David Vignoni - http://www.icon-king.com/ +jsonpack builds a dictionary of all unique values (strings, integers, floats, dates) in the JSON and replaces them with base-36 indices. The result is a flat, ASCII-only string. Repeated keys and values — common in structured data like API responses and GeoJSON — are stored once and referenced everywhere. -## LICENCE +`Date` objects are stored as ISO 8601 strings in the dictionary and marked with a special token in the structure, so `unpack` can restore them as `Date` instances. This is one area where jsonpack goes beyond what `JSON.parse(JSON.stringify())` offers natively. -The MIT License (MIT) -Copyright (c) 2013 Rodrigo González, Sapienlab +--- -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +## Notes -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +- Pack and unpack are synchronous. For large payloads in a browser, run them in a [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API). +- Requires Node.js ≥ 14. +- The packed format is not binary-compatible with other JSON compression libraries. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Licence -[cpm]: https://github.org/kriszyp/cpm -[volo]: http://volojs.org/ -[npm]: http://npmjs.org/ -[download]: https://github.com/sapienlab/jsonpack/archive/master.zip +MIT © 2013 Rodrigo González, SASUD diff --git a/charts/01-scatter-compression-vs-speed.png b/charts/01-scatter-compression-vs-speed.png new file mode 100644 index 0000000..5552c3d Binary files /dev/null and b/charts/01-scatter-compression-vs-speed.png differ diff --git a/charts/02-ratio-per-dataset.png b/charts/02-ratio-per-dataset.png new file mode 100644 index 0000000..5302e66 Binary files /dev/null and b/charts/02-ratio-per-dataset.png differ diff --git a/charts/03-unpack-speed-per-dataset.png b/charts/03-unpack-speed-per-dataset.png new file mode 100644 index 0000000..35be647 Binary files /dev/null and b/charts/03-unpack-speed-per-dataset.png differ diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..45ad661 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,19 @@ +export interface PackOptions { + verbose?: boolean; + debug?: boolean; +} + +export interface UnpackOptions { + verbose?: boolean; +} + +/** + * Packs a JSON value into a compact string representation. + * Date objects are preserved and restored as Date instances on unpack. + */ +export function pack(json: unknown, options?: PackOptions): string; + +/** + * Unpacks a string produced by `pack` back into its original JSON value. + */ +export function unpack(packed: string, options?: UnpackOptions): T; diff --git a/index.js b/index.js new file mode 100644 index 0000000..6a5f988 --- /dev/null +++ b/index.js @@ -0,0 +1,279 @@ +/* + Copyright (c) 2013, Rodrigo González, SASUD All Rights Reserved. + Available via MIT LICENSE. See https://github.com/rgcl/jsonpack/blob/main/LICENSE.md for details. + */ +'use strict'; + +const TOKEN_TRUE = -1; +const TOKEN_FALSE = -2; +const TOKEN_NULL = -3; +const TOKEN_EMPTY_STRING = -4; +const TOKEN_UNDEFINED = -5; +const TOKEN_DATE = -6; + +const _encode = (str) => { + if (typeof str !== 'string') return str; + return str.replace(/[\+ \|\^\%]/g, (a) => ({ + ' ' : '+', + '+' : '%2B', + '|' : '%7C', + '^' : '%5E', + '%' : '%25' + })[a]); +}; + +const _decode = (str) => { + if (typeof str !== 'string') return str; + return str.replace(/\+|%2B|%7C|%5E|%25/g, (a) => ({ + '+' : ' ', + '%2B' : '+', + '%7C' : '|', + '%5E' : '^', + '%25' : '%' + })[a]); +}; + +const _base10To36 = (number) => Number.prototype.toString.call(number, 36).toUpperCase(); +const _base36To10 = (number) => parseInt(number, 36); + +const pack = (json, options = {}) => { + const verbose = options.verbose || false; + + verbose && console.log('Normalize the JSON Object'); + json = typeof json === 'string' ? JSON.parse(json) : json; + + verbose && console.log('Creating empty dictionary'); + const dictionary = { + strings : {}, + integers : {}, + floats : {}, + stringsLen : 0, + integersLen : 0, + floatsLen : 0 + }; + + verbose && console.log('Creating the AST'); + const ast = (function recursiveAstBuilder(item) { + verbose && console.log('Calling recursiveAstBuilder with ' + JSON.stringify(item)); + + if (item === null) return { type: 'null', index: TOKEN_NULL }; + if (typeof item === 'undefined') return { type: 'undefined', index: TOKEN_UNDEFINED }; + + if (item instanceof Date) { + const encoded = _encode(item.toISOString()); + if (encoded in dictionary.strings) { + return { type: 'date', index: dictionary.strings[encoded] }; + } + const index = dictionary.stringsLen++; + dictionary.strings[encoded] = index; + return { type: 'date', index }; + } + + if (Array.isArray(item)) { + const ast = ['@']; + for (let i = 0; i < item.length; i++) { + ast.push(recursiveAstBuilder(item[i])); + } + return ast; + } + + const type = typeof item; + + if (type === 'object') { + const ast = ['$']; + for (const key in item) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue; + ast.push(recursiveAstBuilder(key)); + ast.push(recursiveAstBuilder(item[key])); + } + return ast; + } + + if (item === '') return { type: 'empty', index: TOKEN_EMPTY_STRING }; + + if (type === 'string') { + const encoded = _encode(item); + if (encoded in dictionary.strings) { + return { type: 'strings', index: dictionary.strings[encoded] }; + } + const index = dictionary.stringsLen++; + dictionary.strings[encoded] = index; + return { type: 'strings', index }; + } + + if (type === 'number' && item % 1 === 0) { + const encoded = _base10To36(item); + if (encoded in dictionary.integers) { + return { type: 'integers', index: dictionary.integers[encoded] }; + } + const index = dictionary.integersLen++; + dictionary.integers[encoded] = index; + return { type: 'integers', index }; + } + + if (type === 'number') { + const key = String(item); + if (key in dictionary.floats) { + return { type: 'floats', index: dictionary.floats[key] }; + } + const index = dictionary.floatsLen++; + dictionary.floats[key] = index; + return { type: 'floats', index }; + } + + if (type === 'boolean') { + return { type: 'boolean', index: item ? TOKEN_TRUE : TOKEN_FALSE }; + } + + throw new Error('Unexpected argument of type ' + typeof item); + })(json); + + const stringLength = dictionary.stringsLen; + const integerLength = dictionary.integersLen; + + verbose && console.log('Parsing the dictionary'); + + const getSortedKeys = (dict, len) => { + const arr = new Array(len); + for (const key in dict) arr[dict[key]] = key; + return arr; + }; + + let packed = getSortedKeys(dictionary.strings, dictionary.stringsLen).join('|'); + packed += '^' + getSortedKeys(dictionary.integers, dictionary.integersLen).join('|'); + packed += '^' + getSortedKeys(dictionary.floats, dictionary.floatsLen).join('|'); + + verbose && console.log('Parsing the structure'); + + packed += '^' + (function recursiveParser(item) { + verbose && console.log('Calling recursiveParser with ' + JSON.stringify(item)); + + if (Array.isArray(item)) { + let packed = item[0]; + for (let i = 1; i < item.length; i++) { + packed += recursiveParser(item[i]) + '|'; + } + return (packed[packed.length - 1] === '|' ? packed.slice(0, -1) : packed) + ']'; + } + + const { type, index } = item; + + if (type === 'strings') return _base10To36(index); + if (type === 'integers') return _base10To36(stringLength + index); + if (type === 'floats') return _base10To36(stringLength + integerLength + index); + if (type === 'date') return TOKEN_DATE + '|' + _base10To36(index); + if (type === 'boolean') return index; + if (type === 'null') return TOKEN_NULL; + if (type === 'undefined') return TOKEN_UNDEFINED; + if (type === 'empty') return TOKEN_EMPTY_STRING; + + throw new TypeError('The item is alien!'); + })(ast); + + verbose && console.log('Ending parser'); + + if (options.debug) return { dictionary, ast, packed }; + return packed; +}; + +const unpack = (packed, options = {}) => { + const rawBuffers = packed.split('^'); + const dictionary = []; + + options.verbose && console.log('Building dictionary'); + + let buffer = rawBuffers[0]; + if (buffer !== '') { + buffer.split('|').forEach((s) => dictionary.push(_decode(s))); + } + + buffer = rawBuffers[1]; + if (buffer !== '') { + buffer.split('|').forEach((s) => dictionary.push(_base36To10(s))); + } + + buffer = rawBuffers[2]; + if (buffer !== '') { + buffer.split('|').forEach((s) => dictionary.push(parseFloat(s))); + } + + options.verbose && console.log('Tokenizing the structure'); + + let number36 = ''; + const tokens = []; + const raw = rawBuffers[3]; + for (let i = 0, len = raw.length; i < len; i++) { + const symbol = raw[i]; + if (symbol === '|' || symbol === '$' || symbol === '@' || symbol === ']') { + if (number36) { + tokens.push(_base36To10(number36)); + number36 = ''; + } + if (symbol !== '|') tokens.push(symbol); + } else { + number36 += symbol; + } + } + + const tokensLength = tokens.length; + let tokensIndex = 0; + + options.verbose && console.log('Starting recursive parser'); + + return (function recursiveUnpackerParser() { + const type = tokens[tokensIndex++]; + + if (type === '@') { + const node = []; + for (; tokensIndex < tokensLength; tokensIndex++) { + const value = tokens[tokensIndex]; + if (value === ']') return node; + if (value === '@' || value === '$') { + node.push(recursiveUnpackerParser()); + } else if (value === TOKEN_DATE) { + node.push(new Date(dictionary[tokens[++tokensIndex]])); + } else { + switch (value) { + case TOKEN_TRUE: node.push(true); break; + case TOKEN_FALSE: node.push(false); break; + case TOKEN_NULL: node.push(null); break; + case TOKEN_UNDEFINED: node.push(undefined); break; + case TOKEN_EMPTY_STRING: node.push(''); break; + default: node.push(dictionary[value]); + } + } + } + return node; + } + + if (type === '$') { + const node = {}; + for (; tokensIndex < tokensLength; tokensIndex++) { + let key = tokens[tokensIndex]; + if (key === ']') return node; + key = key === TOKEN_EMPTY_STRING ? '' : dictionary[key]; + + const value = tokens[++tokensIndex]; + if (value === '@' || value === '$') { + node[key] = recursiveUnpackerParser(); + } else if (value === TOKEN_DATE) { + node[key] = new Date(dictionary[tokens[++tokensIndex]]); + } else { + switch (value) { + case TOKEN_TRUE: node[key] = true; break; + case TOKEN_FALSE: node[key] = false; break; + case TOKEN_NULL: node[key] = null; break; + case TOKEN_UNDEFINED: node[key] = undefined; break; + case TOKEN_EMPTY_STRING: node[key] = ''; break; + default: node[key] = dictionary[value]; + } + } + } + return node; + } + + throw new TypeError("Bad token '" + type + "' isn't a type"); + })(); +}; + +module.exports = { pack, unpack }; diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..fc69083 --- /dev/null +++ b/index.mjs @@ -0,0 +1,3 @@ +import { createRequire } from 'module'; +const { pack, unpack } = createRequire(import.meta.url)('./index.js'); +export { pack, unpack }; diff --git a/main.js b/main.js deleted file mode 100644 index 75334f4..0000000 --- a/main.js +++ /dev/null @@ -1,534 +0,0 @@ -/* - Copyright (c) 2013, Rodrigo González, Sapienlab All Rights Reserved. - Available via MIT LICENSE. See https://github.com/roro89/jsonpack/blob/master/LICENSE.md for details. - */ -(function(define) { - - define([], function() { - - var TOKEN_TRUE = -1; - var TOKEN_FALSE = -2; - var TOKEN_NULL = -3; - var TOKEN_EMPTY_STRING = -4; - var TOKEN_UNDEFINED = -5; - - var isArray = Array.isArray; - if (typeof isArray !== "function") { - isArray = function(item) { - if (!item || typeof item !== "object") { - return false; - } - else if (item instanceof Array) { - return true; - } - return Object.prototype.toString.call(item) === '[object Array]'; - } - } - - var pack = function(json, options) { - - // Canonizes the options - options = options || {}; - - // A shorthand for debugging - var verbose = options.verbose || false; - - verbose && console.log('Normalize the JSON Object'); - - // JSON as Javascript Object (Not string representation) - json = typeof json === 'string' ? this.JSON.parse(json) : json; - - verbose && console.log('Creating a empty dictionary'); - - // The dictionary - var dictionary = { - strings : {}, - integers : {}, - floats : {}, - stringsLen: 0, - integersLen: 0, - floatsLen: 0 - }; - - verbose && console.log('Creating the AST'); - - // The AST - var ast = (function recursiveAstBuilder(item) { - - verbose && console.log('Calling recursiveAstBuilder with ' + this.JSON.stringify(item)); - - // The type of the item - var type = typeof item; - - // Case 7: The item is null - if (item === null) { - return { - type : 'null', - index : TOKEN_NULL - }; - } - - //add undefined - if (typeof item === 'undefined') { - return { - type : 'undefined', - index : TOKEN_UNDEFINED - }; - } - - // Case 1: The item is Array Object - if (isArray(item)) { - - // Create a new sub-AST of type Array (@) - var ast = ['@']; - - // Add each items - for (var i in item) { - - if (!item.hasOwnProperty(i)) continue; - - ast.push(recursiveAstBuilder(item[i])); - } - - // And return - return ast; - - } - - // Case 2: The item is Object - if (type === 'object') { - - // Create a new sub-AST of type Object ($) - var ast = ['$']; - - // Add each items - for (var key in item) { - - if (!item.hasOwnProperty(key)) - continue; - - ast.push(recursiveAstBuilder(key)); - ast.push(recursiveAstBuilder(item[key])); - } - - // And return - return ast; - - } - - // Case 3: The item empty string - if (item === '') { - return { - type : 'empty', - index : TOKEN_EMPTY_STRING - }; - } - - // Case 4: The item is String - if (type === 'string') { - item = _encode(item); - // The index of that word in the dictionary - if (item in dictionary.strings) { - // Return the token - return { - type : 'strings', - index : dictionary.strings[item] - } - } else { - // If not, add to the dictionary and actualize the index - var index = dictionary.stringsLen; - dictionary.strings[item] = index; - dictionary.stringsLen += 1; - // Return the token - return { - type : 'strings', - index : index - } - } - } - - // Case 5: The item is integer - if (type === 'number' && item % 1 === 0) { - item = _base10To36(item); - // The index of that number in the dictionary - if(item in dictionary.integers) { - // Return the token - return { - type : 'integers', - index : dictionary.integers[item] - }; - } else { - // If not, add to the dictionary and actualize the index - var index = dictionary.integersLen; - dictionary.integers[item] = index; - dictionary.integersLen += 1; - // Return the token - return { - type : 'integers', - index : index - }; - } - } - - // Case 6: The item is float - if (type === 'number') { - // The index of that number in the dictionary - if(item in dictionary.floats) { - // Return the token - return { - type : 'floats', - index : dictionary.floats[item] - }; - } else { - // If not, add to the dictionary and actualize the index - var index = dictionary.floatsLen; - dictionary.floats[item] = index; - dictionary.floatsLen += 1; - // Return the token - return { - type : 'floats', - index : index - }; - } - } - - // Case 7: The item is boolean - if (type === 'boolean') { - return { - type : 'boolean', - index : item ? TOKEN_TRUE : TOKEN_FALSE - }; - } - - // Default - throw new Error('Unexpected argument of type ' + typeof (item)); - - })(json); - - // A set of shorthands proxies for the length of the dictionaries - var stringLength = dictionary.stringsLen; - var integerLength = dictionary.integersLen; - var floatLength = dictionary.floatsLen; - - verbose && console.log('Parsing the dictionary'); - - // Create a raw dictionary - var packed = _getSortedKeys(dictionary.strings, dictionary.stringsLen).join('|'); - packed += '^' + _getSortedKeys(dictionary.integers, dictionary.integersLen).join('|'); - packed += '^' + _getSortedKeys(dictionary.floats, dictionary.floatsLen).join('|'); - - verbose && console.log('Parsing the structure'); - - // And add the structure - packed += '^' + (function recursiveParser(item) { - - verbose && console.log('Calling a recursiveParser with ' + this.JSON.stringify(item)); - - // If the item is Array, then is a object of - // type [object Object] or [object Array] - if ( item instanceof Array) { - - // The packed resulting - var packed = item.shift(); - - for (var i in item) { - - if (!item.hasOwnProperty(i)) - continue; - - packed += recursiveParser(item[i]) + '|'; - } - - return (packed[packed.length - 1] === '|' ? packed.slice(0, -1) : packed) + ']'; - - } - - // A shorthand proxies - var type = item.type, index = item.index; - - if (type === 'strings') { - // Just return the base 36 of index - return _base10To36(index); - } - - if (type === 'integers') { - // Return a base 36 of index plus stringLength offset - return _base10To36(stringLength + index); - } - - if (type === 'floats') { - // Return a base 36 of index plus stringLength and integerLength offset - return _base10To36(stringLength + integerLength + index); - } - - if (type === 'boolean') { - return item.index; - } - - if (type === 'null') { - return TOKEN_NULL; - } - - if (type === 'undefined') { - return TOKEN_UNDEFINED; - } - - if (type === 'empty') { - return TOKEN_EMPTY_STRING; - } - - throw new TypeError('The item is alien!'); - - })(ast); - - verbose && console.log('Ending parser'); - - // If debug, return a internal representation of dictionary and stuff - if (options.debug) - return { - dictionary : dictionary, - ast : ast, - packed : packed - }; - - return packed; - - }; - - var unpack = function(packed, options) { - - // Canonizes the options - options = options || {}; - - // A raw buffer - var rawBuffers = packed.split('^'); - - // Create a dictionary - options.verbose && console.log('Building dictionary'); - var dictionary = []; - - // Add the strings values - var buffer = rawBuffers[0]; - if (buffer !== '') { - buffer = buffer.split('|'); - options.verbose && console.log('Parse the strings dictionary'); - for (var i=0, n=buffer.length; i", + "license": "MIT", + "homepage": "https://github.com/rgcl/jsonpack", + "repository": { + "type": "git", + "url": "https://github.com/rgcl/jsonpack.git" + }, + "keywords": [ + "compress", + "json", + "pack", + "unpack" + ], + "engines": { + "node": ">=14" + }, + "main": "./index.js", + "module": "./index.mjs", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.js", + "types": "./index.d.ts" + } + }, + "files": [ + "index.js", + "index.mjs", + "index.d.ts" + ], + "scripts": { + "test": "mocha test/*.js" + }, + "devDependencies": { + "mocha": "^10.4.0", + "chai": "^4.4.1" + } } diff --git a/test/test.js b/test/test.js index 3cb7691..e765ddb 100644 --- a/test/test.js +++ b/test/test.js @@ -1,104 +1,92 @@ -/* - Copyright (c) 2013, Rodrigo González, Sapienlab All Rights Reserved. - Available via MIT LICENSE. See https://github.com/roro89/jsonpack/blob/master/LICENSE.md for details. - */ 'use strict'; -describe('jsonpack', function() { - - var assert = require('assert'), - expect = require('expect.js'), - jsonpack = require('../main.js'); - - var plainObject = { - "string" : "hello", - "integer" : 1989, - "float" : 1.2, - "true" : true, - "false" : false, - "null" : null - }, - plainObjectPacked = "string|hello|integer|float|true|false|null^1J9^1.2^$0|1|2|7|3|8|4|-1|5|-2|6|-3]"; - - var deepObject = { - attr1 : plainObject, - attr2 : plainObject, - attr3 : [plainObject, plainObject] - }, - deepObjectPacked = "attr1|string|hello|integer|float|true|false|null|attr2|attr3^1J9^1.2^$0|$1|2|3|A|4|B|5|-1|6|-2|7|-3]|8|$1|2|3|A|4|B|5|-1|6|-2|7|-3]|9|@$1|2|3|A|4|B|5|-1|6|-2|7|-3]|$1|2|3|A|4|B|5|-1|6|-2|7|-3]]]"; - - var arrayObject = [ - plainObject, - deepObject - ], - arrayObjectPacked = "string|hello|integer|float|true|false|null|attr1|attr2|attr3^1J9^1.2^@$0|1|2|A|3|B|4|-1|5|-2|6|-3]|$7|$0|1|2|A|3|B|4|-1|5|-2|6|-3]|8|$0|1|2|A|3|B|4|-1|5|-2|6|-3]|9|@$0|1|2|A|3|B|4|-1|5|-2|6|-3]|$0|1|2|A|3|B|4|-1|5|-2|6|-3]]]]"; - - describe('elemental', function() { - - it('is object', function() { - expect(jsonpack).to.be.an("object"); - }); - - it('has JSON property', function() { - expect(jsonpack.JSON).to.be.an("object"); - }); - - it('has pack method', function() { - expect(jsonpack.pack).to.be.a("function"); - }); - - it('has unpack method', function() { - expect(jsonpack.unpack).to.be.a("function"); - }); - +const assert = require('assert').strict; +const jsonpack = require('../index.js'); + +describe('jsonpack', () => { + + const plainObject = { + string : 'hello', + integer : 1989, + float : 1.2, + true : true, + false : false, + null : null + }; + const plainObjectPacked = 'string|hello|integer|float|true|false|null^1J9^1.2^$0|1|2|7|3|8|4|-1|5|-2|6|-3]'; + + const deepObject = { + attr1 : plainObject, + attr2 : plainObject, + attr3 : [plainObject, plainObject] + }; + const deepObjectPacked = 'attr1|string|hello|integer|float|true|false|null|attr2|attr3^1J9^1.2^$0|$1|2|3|A|4|B|5|-1|6|-2|7|-3]|8|$1|2|3|A|4|B|5|-1|6|-2|7|-3]|9|@$1|2|3|A|4|B|5|-1|6|-2|7|-3]|$1|2|3|A|4|B|5|-1|6|-2|7|-3]]]'; + + const arrayObject = [plainObject, deepObject]; + const arrayObjectPacked = 'string|hello|integer|float|true|false|null|attr1|attr2|attr3^1J9^1.2^@$0|1|2|A|3|B|4|-1|5|-2|6|-3]|$7|$0|1|2|A|3|B|4|-1|5|-2|6|-3]|8|$0|1|2|A|3|B|4|-1|5|-2|6|-3]|9|@$0|1|2|A|3|B|4|-1|5|-2|6|-3]|$0|1|2|A|3|B|4|-1|5|-2|6|-3]]]]'; + + describe('module shape', () => { + it('exports pack function', () => assert.equal(typeof jsonpack.pack, 'function')); + it('exports unpack function', () => assert.equal(typeof jsonpack.unpack, 'function')); }); - describe('pack', function() { + describe('pack', () => { + it('empty object', () => assert.equal(jsonpack.pack({}), '^^^$]')); + it('empty array', () => assert.equal(jsonpack.pack([]), '^^^@]')); + it('plain object', () => assert.equal(jsonpack.pack(plainObject), plainObjectPacked)); + it('deep object', () => assert.equal(jsonpack.pack(deepObject), deepObjectPacked)); + it('complex array', () => assert.equal(jsonpack.pack(arrayObject), arrayObjectPacked)); - it('empty object', function() { - expect(jsonpack.pack({})).to.eql("^^^$]"); + it('deduplicates strings with escape characters', () => { + const packed = jsonpack.pack({ attr1: ' ', attr2: '+' }); + assert.equal(packed, 'attr1|+|attr2|%2B^^^$0|1|2|3]'); }); - it('empty array', function() { - expect(jsonpack.pack([])).to.eql("^^^@]"); + it('round-trips Date as Date instance', () => { + const date = new Date('2024-01-15T12:00:00.000Z'); + const result = jsonpack.unpack(jsonpack.pack({ d: date })); + assert.ok(result.d instanceof Date, 'should be a Date instance'); + assert.equal(result.d.toISOString(), date.toISOString()); }); - it('plain object', function() { - expect(jsonpack.pack(plainObject)).to.eql(plainObjectPacked); + it('deduplicates repeated Date values', () => { + const date = new Date('2024-01-15T12:00:00.000Z'); + const result = jsonpack.unpack(jsonpack.pack({ a: date, b: date })); + assert.ok(result.a instanceof Date); + assert.ok(result.b instanceof Date); + assert.equal(result.a.toISOString(), result.b.toISOString()); }); - it('deep object', function() { - expect(jsonpack.pack(deepObject)).to.eql(deepObjectPacked); + it('round-trips Date in array', () => { + const dates = [new Date('2024-01-01T00:00:00.000Z'), new Date('2024-06-15T12:30:00.000Z')]; + const result = jsonpack.unpack(jsonpack.pack(dates)); + assert.ok(result[0] instanceof Date); + assert.ok(result[1] instanceof Date); + assert.equal(result[0].toISOString(), dates[0].toISOString()); + assert.equal(result[1].toISOString(), dates[1].toISOString()); }); - - it('complex array object', function() { - expect(jsonpack.pack(arrayObject)).to.eql(arrayObjectPacked); - }); - }); - describe('unpack', function() { - - it('empty object', function() { - expect(jsonpack.unpack("^^^$]")).to.eql({}); - }); - - it('empty array', function() { - expect(jsonpack.unpack("^^^@]")).to.eql([]); - }); - - it('plain object', function() { - expect(jsonpack.unpack(plainObjectPacked)).to.eql(plainObject); - }); - - it('deep object', function() { - expect(jsonpack.unpack(deepObjectPacked)).to.eql(deepObject); - }); + describe('unpack', () => { + it('empty object', () => assert.deepEqual(jsonpack.unpack('^^^$]'), {})); + it('empty array', () => assert.deepEqual(jsonpack.unpack('^^^@]'), [])); + it('plain object', () => assert.deepEqual(jsonpack.unpack(plainObjectPacked), plainObject)); + it('deep object', () => assert.deepEqual(jsonpack.unpack(deepObjectPacked), deepObject)); + it('complex array', () => assert.deepEqual(jsonpack.unpack(arrayObjectPacked), arrayObject)); + }); - it('complex array object', function() { - expect(jsonpack.unpack(arrayObjectPacked)).to.eql(arrayObject); + describe('round-trip', () => { + const roundTrip = (obj) => jsonpack.unpack(jsonpack.pack(obj)); + + it('null values', () => assert.deepEqual(roundTrip({ a: null, b: [null] }), { a: null, b: [null] })); + it('undefined values', () => assert.deepEqual(roundTrip({ a: undefined }), { a: undefined })); + it('empty strings', () => assert.deepEqual(roundTrip({ a: '', b: ['', ''] }), { a: '', b: ['', ''] })); + it('booleans', () => assert.deepEqual(roundTrip({ t: true, f: false }), { t: true, f: false })); + it('nested arrays', () => assert.deepEqual(roundTrip([[1, 2], [3, 4]]), [[1, 2], [3, 4]])); + it('strings with special characters', () => { + const obj = { space: 'hello world', plus: 'a+b', pipe: 'a|b', caret: 'a^b', percent: '50%' }; + assert.deepEqual(roundTrip(obj), obj); }); - }); });