| import * as assert from 'uvu/assert'; | ||
| import { suite } from 'uvu'; | ||
| import { valid_array_indices } from './utils.js'; | ||
| const test = suite('valid_array_indices'); | ||
| test('returns all indices for a normal dense array', () => { | ||
| const arr = ['a', 'b', 'c']; | ||
| assert.equal(valid_array_indices(arr), ['0', '1', '2']); | ||
| }); | ||
| test('returns empty array for an empty array', () => { | ||
| assert.equal(valid_array_indices([]), []); | ||
| }); | ||
| test('returns populated indices for a sparse array', () => { | ||
| const arr = [, 'b', ,]; | ||
| assert.equal(valid_array_indices(arr), ['1']); | ||
| }); | ||
| test('strips non-numeric properties from a dense array', () => { | ||
| const arr = ['a', 'b']; | ||
| arr.foo = 'x'; | ||
| arr.bar = 42; | ||
| assert.equal(valid_array_indices(arr), ['0', '1']); | ||
| }); | ||
| test('strips non-numeric properties from a very sparse array', () => { | ||
| const arr = []; | ||
| arr[1_000_000] = 'x'; | ||
| arr.foo = 'should be ignored'; | ||
| assert.equal(valid_array_indices(arr), ['1000000']); | ||
| }); | ||
| test('returns empty array when only non-numeric properties exist', () => { | ||
| const arr = []; | ||
| arr.foo = 'x'; | ||
| arr.bar = 42; | ||
| assert.equal(valid_array_indices(arr), []); | ||
| }); | ||
| test('handles multiple non-numeric properties after indices', () => { | ||
| const arr = [1, 2, 3]; | ||
| arr.a = 'x'; | ||
| arr.b = 'y'; | ||
| arr.c = 'z'; | ||
| assert.equal(valid_array_indices(arr), ['0', '1', '2']); | ||
| }); | ||
| test('handles a single-element array with non-numeric property', () => { | ||
| const arr = ['only']; | ||
| arr.extra = true; | ||
| assert.equal(valid_array_indices(arr), ['0']); | ||
| }); | ||
| test('handles array properties pretending to be indices', () => { | ||
| const arr = ['a', 'b']; | ||
| arr[-1] = 'negative index'; | ||
| arr[2**32 - 1] = 'too large index'; | ||
| assert.equal(valid_array_indices(arr), ['0', '1']); | ||
| }) | ||
| test.run(); |
+2
-2
| { | ||
| "name": "devalue", | ||
| "description": "Gets the job done when JSON.stringify can't", | ||
| "version": "5.6.2", | ||
| "version": "5.6.3", | ||
| "repository": "sveltejs/devalue", | ||
@@ -35,4 +35,4 @@ "sideEffects": false, | ||
| "build": "dts-buddy", | ||
| "test": "uvu test" | ||
| "test": "uvu" | ||
| } | ||
| } |
+1
-0
@@ -7,1 +7,2 @@ export const UNDEFINED = -1; | ||
| export const NEGATIVE_ZERO = -6; | ||
| export const SPARSE = -7; |
+13
-1
@@ -8,2 +8,3 @@ import { decode64 } from './base64.js'; | ||
| POSITIVE_INFINITY, | ||
| SPARSE, | ||
| UNDEFINED | ||
@@ -205,2 +206,13 @@ } from './constants.js'; | ||
| } | ||
| } else if (value[0] === SPARSE) { | ||
| // Sparse array encoding: [SPARSE, length, idx, val, idx, val, ...] | ||
| const len = value[1]; | ||
| const array = new Array(len); | ||
| hydrated[index] = array; | ||
| for (let i = 2; i < value.length; i += 2) { | ||
| const idx = value[i]; | ||
| array[idx] = hydrate(value[i + 1]); | ||
| } | ||
| } else { | ||
@@ -222,3 +234,3 @@ const array = new Array(value.length); | ||
| for (const key in value) { | ||
| for (const key of Object.keys(value)) { | ||
| if (key === '__proto__') { | ||
@@ -225,0 +237,0 @@ throw new Error('Cannot parse an object with a `__proto__` property'); |
+91
-6
@@ -8,3 +8,4 @@ import { | ||
| stringify_key, | ||
| stringify_string | ||
| stringify_string, | ||
| valid_array_indices | ||
| } from './utils.js'; | ||
@@ -17,2 +18,3 @@ import { | ||
| POSITIVE_INFINITY, | ||
| SPARSE, | ||
| UNDEFINED | ||
@@ -110,3 +112,12 @@ } from './constants.js'; | ||
| case 'Array': | ||
| case 'Array': { | ||
| // For dense arrays (no holes), we iterate normally. | ||
| // When we encounter the first hole, we call Object.keys | ||
| // to determine the sparseness, then decide between: | ||
| // - HOLE encoding: [-2, val, -2, ...] (default) | ||
| // - Sparse encoding: [-7, length, idx, val, ...] (for very sparse arrays) | ||
| // Only the sparse path avoids iterating every slot, which | ||
| // is what protects against the DoS of e.g. `arr[1000000] = 1`. | ||
| let mostly_dense = false; | ||
| str = '['; | ||
@@ -117,8 +128,63 @@ | ||
| if (i in thing) { | ||
| if (Object.hasOwn(thing, i)) { | ||
| keys.push(`[${i}]`); | ||
| str += flatten(thing[i]); | ||
| keys.pop(); | ||
| } else if (mostly_dense) { | ||
| // Use dense encoding. The heuristic guarantees the | ||
| // array is only mildly sparse, so iterating over every | ||
| // slot is fine. | ||
| str += HOLE; | ||
| } else { | ||
| str += HOLE; | ||
| // Decide between HOLE encoding and sparse encoding. | ||
| // | ||
| // HOLE encoding: each hole is serialized as the HOLE | ||
| // sentinel (-2). For example, [, "a", ,] becomes | ||
| // [-2, 0, -2]. Each hole costs 3 chars ("-2" + comma). | ||
| // | ||
| // Sparse encoding: lists only populated indices. | ||
| // For example, [, "a", ,] becomes [-7, 3, 1, 0] — the | ||
| // -7 sentinel, the array length (3), then index-value | ||
| // pairs. This avoids paying per-hole, but each element | ||
| // costs extra chars to write its index. | ||
| // | ||
| // The values are the same size either way, so the | ||
| // choice comes down to structural overhead: | ||
| // | ||
| // HOLE overhead: | ||
| // 3 chars per hole ("-2" + comma) | ||
| // = (L - P) * 3 | ||
| // | ||
| // Sparse overhead: | ||
| // "-7," — 3 chars (sparse sentinel + comma) | ||
| // + length + "," — (d + 1) chars (array length + comma) | ||
| // + per element: index + "," — (d + 1) chars | ||
| // = (4 + d) + P * (d + 1) | ||
| // | ||
| // where L is the array length, P is the number of | ||
| // populated elements, and d is the number of digits | ||
| // in L (an upper bound on the digits in any index). | ||
| // | ||
| // Sparse encoding is cheaper when: | ||
| // (4 + d) + P * (d + 1) < (L - P) * 3 | ||
| const populated_keys = valid_array_indices(/** @type {any[]} */ (thing)); | ||
| const population = populated_keys.length; | ||
| const d = String(thing.length).length; | ||
| const hole_cost = (thing.length - population) * 3; | ||
| const sparse_cost = 4 + d + population * (d + 1); | ||
| if (hole_cost > sparse_cost) { | ||
| str = '[' + SPARSE + ',' + thing.length; | ||
| for (let j = 0; j < populated_keys.length; j++) { | ||
| const key = populated_keys[j]; | ||
| keys.push(`[${key}]`); | ||
| str += ',' + key + ',' + flatten(thing[key]); | ||
| keys.pop(); | ||
| } | ||
| break; | ||
| } else { | ||
| mostly_dense = true; | ||
| str += HOLE; | ||
| } | ||
| } | ||
@@ -130,2 +196,3 @@ } | ||
| break; | ||
| } | ||
@@ -225,3 +292,12 @@ case 'Set': | ||
| str = '["null"'; | ||
| for (const key in thing) { | ||
| for (const key of Object.keys(thing)) { | ||
| if (key === '__proto__') { | ||
| throw new DevalueError( | ||
| `Cannot stringify objects with __proto__ keys`, | ||
| keys, | ||
| thing, | ||
| value | ||
| ); | ||
| } | ||
| keys.push(stringify_key(key)); | ||
@@ -235,3 +311,12 @@ str += `,${stringify_string(key)},${flatten(thing[key])}`; | ||
| let started = false; | ||
| for (const key in thing) { | ||
| for (const key of Object.keys(thing)) { | ||
| if (key === '__proto__') { | ||
| throw new DevalueError( | ||
| `Cannot stringify objects with __proto__ keys`, | ||
| keys, | ||
| thing, | ||
| value | ||
| ); | ||
| } | ||
| if (started) str += ','; | ||
@@ -238,0 +323,0 @@ started = true; |
+90
-7
@@ -9,3 +9,4 @@ import { | ||
| stringify_key, | ||
| stringify_string | ||
| stringify_string, | ||
| valid_array_indices | ||
| } from './utils.js'; | ||
@@ -135,3 +136,12 @@ | ||
| for (const key in thing) { | ||
| for (const key of Object.keys(thing)) { | ||
| if (key === '__proto__') { | ||
| throw new DevalueError( | ||
| `Cannot stringify objects with __proto__ keys`, | ||
| keys, | ||
| thing, | ||
| value | ||
| ); | ||
| } | ||
| keys.push(stringify_key(key)); | ||
@@ -195,8 +205,81 @@ walk(thing[key]); | ||
| case 'Array': | ||
| const members = /** @type {any[]} */ (thing).map((v, i) => | ||
| i in thing ? stringify(v) : '' | ||
| ); | ||
| case 'Array': { | ||
| // For dense arrays (no holes), we iterate normally. | ||
| // When we encounter the first hole, we call Object.keys | ||
| // to determine the sparseness, then decide between: | ||
| // - Array literal with holes: [,"a",,] (default) | ||
| // - Object.assign: Object.assign(Array(n),{...}) (for very sparse arrays) | ||
| // Only the Object.assign path avoids iterating every slot, which | ||
| // is what protects against the DoS of e.g. `arr[1000000] = 1`. | ||
| let has_holes = false; | ||
| let result = '['; | ||
| for (let i = 0; i < thing.length; i += 1) { | ||
| if (i > 0) result += ','; | ||
| if (Object.hasOwn(thing, i)) { | ||
| result += stringify(thing[i]); | ||
| } else if (!has_holes) { | ||
| // Decide between array literal and Object.assign. | ||
| // | ||
| // Array literal: holes are consecutive commas. | ||
| // For example, [, "a", ,] is written as [,"a",,]. | ||
| // Each hole costs 1 char (a comma). | ||
| // | ||
| // Object.assign: populated indices are listed explicitly. | ||
| // For example, [, "a", ,] would be written as | ||
| // Object.assign(Array(3),{1:"a"}). This avoids paying | ||
| // per-hole, but has a large fixed overhead for the | ||
| // "Object.assign(Array(n),{...})" wrapper, and each | ||
| // element costs extra chars for its index and colon. | ||
| // | ||
| // The serialized values are the same size either way, so | ||
| // the choice comes down to the structural overhead: | ||
| // | ||
| // Array literal overhead: | ||
| // 1 char per element or hole (comma separators) | ||
| // + 2 chars for "[" and "]" | ||
| // = L + 2 | ||
| // | ||
| // Object.assign overhead: | ||
| // "Object.assign(Array(" — 20 chars | ||
| // + length — d chars | ||
| // + "),{" — 3 chars | ||
| // + for each populated element: | ||
| // index + ":" + "," — (d + 2) chars | ||
| // + "})" — 2 chars | ||
| // = (25 + d) + P * (d + 2) | ||
| // | ||
| // where L is the array length, P is the number of | ||
| // populated elements, and d is the number of digits | ||
| // in L (an upper bound on the digits in any index). | ||
| // | ||
| // Object.assign is cheaper when: | ||
| // (25 + d) + P * (d + 2) < L + 2 | ||
| const populated_keys = valid_array_indices(/** @type {any[]} */ (thing)); | ||
| const population = populated_keys.length; | ||
| const d = String(thing.length).length; | ||
| const hole_cost = thing.length + 2; | ||
| const sparse_cost = (25 + d) + population * (d + 2); | ||
| if (hole_cost > sparse_cost) { | ||
| const entries = populated_keys | ||
| .map((k) => `${k}:${stringify(thing[k])}`) | ||
| .join(','); | ||
| return `Object.assign(Array(${thing.length}),{${entries}})`; | ||
| } | ||
| // Re-process this index as a hole in the array literal | ||
| has_holes = true; | ||
| i -= 1; | ||
| } | ||
| // else: already decided on array literal, hole is just an empty slot | ||
| // (the comma separator is all we need — no content for this position) | ||
| } | ||
| const tail = thing.length === 0 || thing.length - 1 in thing ? '' : ','; | ||
| return `[${members.join(',')}${tail}]`; | ||
| return result + tail + ']'; | ||
| } | ||
@@ -203,0 +286,0 @@ case 'Set': |
+30
-0
@@ -119,1 +119,31 @@ /** @type {Record<string, string>} */ | ||
| } | ||
| /** @param {string} s */ | ||
| function is_valid_array_index(s) { | ||
| if (s.length === 0) return false; | ||
| if (s.length > 1 && s.charCodeAt(0) === 48) return false; // leading zero | ||
| for (let i = 0; i < s.length; i++) { | ||
| const c = s.charCodeAt(i); | ||
| if (c < 48 || c > 57) return false; | ||
| } | ||
| // by this point we know it's a string of digits, but it has to be within the range of valid array indices | ||
| const n = +s; | ||
| if (n >= 2 ** 32 - 1) return false; | ||
| if (n < 0) return false; | ||
| return true; | ||
| } | ||
| /** | ||
| * Finds the populated indices of an array. | ||
| * @param {unknown[]} array | ||
| */ | ||
| export function valid_array_indices(array) { | ||
| const keys = Object.keys(array); | ||
| for (var i = keys.length - 1; i >= 0; i--) { | ||
| if (is_valid_array_index(keys[i])) { | ||
| break; | ||
| } | ||
| } | ||
| keys.length = i + 1; | ||
| return keys; | ||
| } |
@@ -23,4 +23,4 @@ { | ||
| ], | ||
| "mappings": ";;;;;iBAqBgBA,MAAMA;;;;;iBCGNC,SAASA;cCXZC,YAAYA;;;;;;;;;;;;;;iBCETC,KAAKA;;;;;iBASLC,SAASA", | ||
| "mappings": ";;;;;iBAsBgBA,MAAMA;;;;;iBCINC,SAASA;cCbZC,YAAYA;;;;;;;;;;;;;;iBCGTC,KAAKA;;;;;iBASLC,SAASA", | ||
| "ignoreList": [] | ||
| } |
45901
24.67%14
7.69%1249
24.53%