Skip to content

Commit b92d75b

Browse files
authored
Add JS Map support for encoding & decoding (#5)
1 parent 0d6e172 commit b92d75b

9 files changed

+684
-63
lines changed

Diff for: src/Decoder.ts

+87-20
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,50 @@ export type DecoderOptions<ContextType = undefined> = Readonly<
3333
*
3434
* This is useful if the strings may contain invalid UTF-8 sequences.
3535
*
36-
* Note that this option only applies to string values, not map keys. Additionally, when
37-
* enabled, raw string length is limited by the maxBinLength option.
36+
* When enabled, raw string length is limited by the maxBinLength option.
37+
*
38+
* Note that this option only applies to string values, not map keys. See `rawBinaryStringKeys`
39+
* for map keys.
40+
*/
41+
rawBinaryStringValues: boolean;
42+
43+
/**
44+
* By default, map keys will be decoded as UTF-8 strings. However, if this option is true, map
45+
* keys will be returned as Uint8Arrays without additional decoding.
46+
*
47+
* Requires `useMap` to be true, since plain objects do not support binary keys.
48+
*
49+
* When enabled, raw string length is limited by the maxBinLength option.
50+
*
51+
* Note that this option only applies to map keys, not string values. See `rawBinaryStringValues`
52+
* for string values.
53+
*/
54+
rawBinaryStringKeys: boolean;
55+
56+
/**
57+
* If true, the decoder will use the Map object to store map values. If false, it will use plain
58+
* objects. Defaults to false.
59+
*
60+
* Besides the type of container, the main difference is that Map objects support a wider range
61+
* of key types. Plain objects only support string keys (though you can enable
62+
* `supportObjectNumberKeys` to coerce number keys to strings), while Map objects support
63+
* strings, numbers, bigints, and Uint8Arrays.
64+
*/
65+
useMap: boolean;
66+
67+
/**
68+
* If true, the decoder will support decoding numbers as map keys on plain objects. Defaults to
69+
* false.
70+
*
71+
* Note that any numbers used as object keys will be converted to strings, so there is a risk of
72+
* key collision as well as the inability to re-encode the object to the same representation.
73+
*
74+
* This option is ignored if `useMap` is true.
75+
*
76+
* This is useful for backwards compatibility before `useMap` was introduced. Consider instead
77+
* using `useMap` for new code.
3878
*/
39-
useRawBinaryStrings: boolean;
79+
supportObjectNumberKeys: boolean;
4080

4181
/**
4282
* Maximum string length.
@@ -82,18 +122,22 @@ const STATE_ARRAY = "array";
82122
const STATE_MAP_KEY = "map_key";
83123
const STATE_MAP_VALUE = "map_value";
84124

85-
type MapKeyType = string | number;
125+
type MapKeyType = string | number | bigint | Uint8Array;
86126

87-
const isValidMapKeyType = (key: unknown): key is MapKeyType => {
88-
return typeof key === "string" || typeof key === "number";
89-
};
127+
function isValidMapKeyType(key: unknown, useMap: boolean, supportObjectNumberKeys: boolean): key is MapKeyType {
128+
if (useMap) {
129+
return typeof key === "string" || typeof key === "number" || typeof key === "bigint" || key instanceof Uint8Array;
130+
}
131+
// Plain objects support a more limited set of key types
132+
return typeof key === "string" || (supportObjectNumberKeys && typeof key === "number");
133+
}
90134

91135
type StackMapState = {
92136
type: typeof STATE_MAP_KEY | typeof STATE_MAP_VALUE;
93137
size: number;
94138
key: MapKeyType | null;
95139
readCount: number;
96-
map: Record<string, unknown>;
140+
map: Record<string, unknown> | Map<MapKeyType, unknown>;
97141
};
98142

99143
type StackArrayState = {
@@ -107,6 +151,8 @@ class StackPool {
107151
private readonly stack: Array<StackState> = [];
108152
private stackHeadPosition = -1;
109153

154+
constructor(private readonly useMap: boolean) {}
155+
110156
public get length(): number {
111157
return this.stackHeadPosition + 1;
112158
}
@@ -130,7 +176,7 @@ class StackPool {
130176
state.type = STATE_MAP_KEY;
131177
state.readCount = 0;
132178
state.size = size;
133-
state.map = {};
179+
state.map = this.useMap ? new Map() : {};
134180
}
135181

136182
private getUninitializedStateFromPool() {
@@ -213,7 +259,10 @@ export class Decoder<ContextType = undefined> {
213259
private readonly extensionCodec: ExtensionCodecType<ContextType>;
214260
private readonly context: ContextType;
215261
private readonly intMode: IntMode;
216-
private readonly useRawBinaryStrings: boolean;
262+
private readonly rawBinaryStringValues: boolean;
263+
private readonly rawBinaryStringKeys: boolean;
264+
private readonly useMap: boolean;
265+
private readonly supportObjectNumberKeys: boolean;
217266
private readonly maxStrLength: number;
218267
private readonly maxBinLength: number;
219268
private readonly maxArrayLength: number;
@@ -227,20 +276,29 @@ export class Decoder<ContextType = undefined> {
227276
private view = EMPTY_VIEW;
228277
private bytes = EMPTY_BYTES;
229278
private headByte = HEAD_BYTE_REQUIRED;
230-
private readonly stack = new StackPool();
279+
private readonly stack: StackPool;
231280

232281
public constructor(options?: DecoderOptions<ContextType>) {
233282
this.extensionCodec = options?.extensionCodec ?? (ExtensionCodec.defaultCodec as ExtensionCodecType<ContextType>);
234283
this.context = (options as { context: ContextType } | undefined)?.context as ContextType; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
235284

236285
this.intMode = options?.intMode ?? (options?.useBigInt64 ? IntMode.AS_ENCODED : IntMode.UNSAFE_NUMBER);
237-
this.useRawBinaryStrings = options?.useRawBinaryStrings ?? false;
286+
this.rawBinaryStringValues = options?.rawBinaryStringValues ?? false;
287+
this.rawBinaryStringKeys = options?.rawBinaryStringKeys ?? false;
288+
this.useMap = options?.useMap ?? false;
289+
this.supportObjectNumberKeys = options?.supportObjectNumberKeys ?? false;
238290
this.maxStrLength = options?.maxStrLength ?? UINT32_MAX;
239291
this.maxBinLength = options?.maxBinLength ?? UINT32_MAX;
240292
this.maxArrayLength = options?.maxArrayLength ?? UINT32_MAX;
241293
this.maxMapLength = options?.maxMapLength ?? UINT32_MAX;
242294
this.maxExtLength = options?.maxExtLength ?? UINT32_MAX;
243295
this.keyDecoder = options?.keyDecoder !== undefined ? options.keyDecoder : sharedCachedKeyDecoder;
296+
297+
if (this.rawBinaryStringKeys && !this.useMap) {
298+
throw new Error("rawBinaryStringKeys is only supported when useMap is true");
299+
}
300+
301+
this.stack = new StackPool(this.useMap);
244302
}
245303

246304
private reinitializeState() {
@@ -404,7 +462,7 @@ export class Decoder<ContextType = undefined> {
404462
this.complete();
405463
continue DECODE;
406464
} else {
407-
object = {};
465+
object = this.useMap ? new Map() : {};
408466
}
409467
} else if (headByte < 0xa0) {
410468
// fixarray (1001 xxxx) 0x90 - 0x9f
@@ -571,10 +629,15 @@ export class Decoder<ContextType = undefined> {
571629
continue DECODE;
572630
}
573631
} else if (state.type === STATE_MAP_KEY) {
574-
if (!isValidMapKeyType(object)) {
575-
throw new DecodeError("The type of key must be string or number but " + typeof object);
632+
if (!isValidMapKeyType(object, this.useMap, this.supportObjectNumberKeys)) {
633+
const acceptableTypes = this.useMap
634+
? "string, number, bigint, or Uint8Array"
635+
: this.supportObjectNumberKeys
636+
? "string or number"
637+
: "string";
638+
throw new DecodeError(`The type of key must be ${acceptableTypes} but got ${typeof object}`);
576639
}
577-
if (object === "__proto__") {
640+
if (!this.useMap && object === "__proto__") {
578641
throw new DecodeError("The key __proto__ is not allowed");
579642
}
580643

@@ -584,7 +647,11 @@ export class Decoder<ContextType = undefined> {
584647
} else {
585648
// it must be `state.type === State.MAP_VALUE` here
586649

587-
state.map[state.key!] = object;
650+
if (this.useMap) {
651+
(state.map as Map<MapKeyType, unknown>).set(state.key!, object);
652+
} else {
653+
(state.map as Record<string, unknown>)[state.key as string] = object;
654+
}
588655
state.readCount++;
589656

590657
if (state.readCount === state.size) {
@@ -650,10 +717,10 @@ export class Decoder<ContextType = undefined> {
650717
}
651718

652719
private decodeString(byteLength: number, headerOffset: number): string | Uint8Array {
653-
if (!this.useRawBinaryStrings || this.stateIsMapKey()) {
654-
return this.decodeUtf8String(byteLength, headerOffset);
720+
if (this.stateIsMapKey() ? this.rawBinaryStringKeys : this.rawBinaryStringValues) {
721+
return this.decodeBinary(byteLength, headerOffset);
655722
}
656-
return this.decodeBinary(byteLength, headerOffset);
723+
return this.decodeUtf8String(byteLength, headerOffset);
657724
}
658725

659726
private decodeUtf8String(byteLength: number, headerOffset: number): string {

Diff for: src/Encoder.ts

+66-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { utf8Count, utf8Encode } from "./utils/utf8";
22
import { ExtensionCodec, ExtensionCodecType } from "./ExtensionCodec";
33
import { setInt64, setUint64 } from "./utils/int";
4-
import { ensureUint8Array } from "./utils/typedArrays";
4+
import { ensureUint8Array, compareUint8Arrays } from "./utils/typedArrays";
55
import type { ExtData } from "./ExtData";
66
import type { ContextOf } from "./context";
77

@@ -41,6 +41,15 @@ export type EncoderOptions<ContextType = undefined> = Partial<
4141
* binary is canonical and thus comparable to another encoded binary.
4242
*
4343
* Defaults to `false`. If enabled, it spends more time in encoding objects.
44+
*
45+
* If enabled, the encoder will throw an error if the NaN value is included in the keys of a
46+
* map, since it is not comparable.
47+
*
48+
* If enabled and the keys of a map include multiple different types, each type will be sorted
49+
* separately, and the order of the types will be as follows:
50+
* 1. Numbers (including bigints)
51+
* 2. Strings
52+
* 3. Binary data
4453
*/
4554
sortKeys: boolean;
4655

@@ -321,8 +330,10 @@ export class Encoder<ContextType = undefined> {
321330
// this is here instead of in doEncode so that we can try encoding with an extension first,
322331
// otherwise we would break existing extensions for bigints
323332
this.encodeBigInt(object);
333+
} else if (object instanceof Map) {
334+
this.encodeMap(object, depth);
324335
} else if (typeof object === "object") {
325-
this.encodeMap(object as Record<string, unknown>, depth);
336+
this.encodeMapObject(object as Record<string, unknown>, depth);
326337
} else {
327338
// symbol, function and other special object come here unless extensionCodec handles them.
328339
throw new Error(`Unrecognized object: ${Object.prototype.toString.apply(object)}`);
@@ -371,25 +382,60 @@ export class Encoder<ContextType = undefined> {
371382
}
372383
}
373384

374-
private countWithoutUndefined(object: Record<string, unknown>, keys: ReadonlyArray<string>): number {
385+
private countWithoutUndefined(map: Map<unknown, unknown>, keys: ReadonlyArray<unknown>): number {
375386
let count = 0;
376387

377388
for (const key of keys) {
378-
if (object[key] !== undefined) {
389+
if (map.get(key) !== undefined) {
379390
count++;
380391
}
381392
}
382393

383394
return count;
384395
}
385396

386-
private encodeMap(object: Record<string, unknown>, depth: number) {
387-
const keys = Object.keys(object);
397+
private sortMapKeys(keys: Array<unknown>): Array<unknown> {
398+
const numericKeys: Array<number | bigint> = [];
399+
const stringKeys: Array<string> = [];
400+
const binaryKeys: Array<Uint8Array> = [];
401+
for (const key of keys) {
402+
if (typeof key === "number") {
403+
if (isNaN(key)) {
404+
throw new Error("Cannot sort map keys with NaN value");
405+
}
406+
numericKeys.push(key);
407+
} else if (typeof key === "bigint") {
408+
numericKeys.push(key);
409+
} else if (typeof key === "string") {
410+
stringKeys.push(key);
411+
} else if (ArrayBuffer.isView(key)) {
412+
binaryKeys.push(ensureUint8Array(key));
413+
} else {
414+
throw new Error(`Unsupported map key type: ${Object.prototype.toString.apply(key)}`);
415+
}
416+
}
417+
numericKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); // Avoid using === to compare numbers and bigints
418+
stringKeys.sort();
419+
binaryKeys.sort(compareUint8Arrays);
420+
// At the moment this arbitrarily orders the keys as numeric, string, binary
421+
return ([] as Array<unknown>).concat(numericKeys, stringKeys, binaryKeys);
422+
}
423+
424+
private encodeMapObject(object: Record<string, unknown>, depth: number) {
425+
this.encodeMap(new Map<string, unknown>(Object.entries(object)), depth);
426+
}
427+
428+
private encodeMap(map: Map<unknown, unknown>, depth: number) {
429+
let keys = Array.from(map.keys());
388430
if (this.sortKeys) {
389-
keys.sort();
431+
keys = this.sortMapKeys(keys);
390432
}
391433

392-
const size = this.ignoreUndefined ? this.countWithoutUndefined(object, keys) : keys.length;
434+
// Map keys may encode to the same underlying value. For example, the number 3 and the bigint 3.
435+
// This is also possible with ArrayBufferViews. We may want to introduce a new encoding option
436+
// which checks for duplicate keys in this sense and throws an error if they are found.
437+
438+
const size = this.ignoreUndefined ? this.countWithoutUndefined(map, keys) : keys.length;
393439

394440
if (size < 16) {
395441
// fixmap
@@ -407,10 +453,20 @@ export class Encoder<ContextType = undefined> {
407453
}
408454

409455
for (const key of keys) {
410-
const value = object[key];
456+
const value = map.get(key);
411457

412458
if (!(this.ignoreUndefined && value === undefined)) {
413-
this.encodeString(key);
459+
if (typeof key === "string") {
460+
this.encodeString(key);
461+
} else if (typeof key === "number") {
462+
this.encodeNumber(key);
463+
} else if (typeof key === "bigint") {
464+
this.encodeBigInt(key);
465+
} else if (ArrayBuffer.isView(key)) {
466+
this.encodeBinary(key);
467+
} else {
468+
throw new Error(`Unsupported map key type: ${Object.prototype.toString.apply(key)}`);
469+
}
414470
this.doEncode(value, depth + 1);
415471
}
416472
}

Diff for: src/utils/typedArrays.ts

+11
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,14 @@ export function createDataView(buffer: ArrayLike<number> | ArrayBufferView | Arr
1919
const bufferView = ensureUint8Array(buffer);
2020
return new DataView(bufferView.buffer, bufferView.byteOffset, bufferView.byteLength);
2121
}
22+
23+
export function compareUint8Arrays(a: Uint8Array, b: Uint8Array): number {
24+
const length = Math.min(a.length, b.length);
25+
for (let i = 0; i < length; i++) {
26+
const diff = a[i]! - b[i]!;
27+
if (diff !== 0) {
28+
return diff;
29+
}
30+
}
31+
return a.length - b.length;
32+
}

Diff for: test/codec-bigint.test.ts

+15-15
Original file line numberDiff line numberDiff line change
@@ -253,24 +253,24 @@ describe("codec BigInt", () => {
253253
const encoded = encode(value, { extensionCodec });
254254
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
255255
});
256-
});
257256

258-
it("encodes and decodes 100n", () => {
259-
const value = BigInt(100);
260-
const encoded = encode(value, { extensionCodec });
261-
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
262-
});
257+
it("encodes and decodes 100n", () => {
258+
const value = BigInt(100);
259+
const encoded = encode(value, { extensionCodec });
260+
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
261+
});
263262

264-
it("encodes and decodes -100n", () => {
265-
const value = BigInt(-100);
266-
const encoded = encode(value, { extensionCodec });
267-
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
268-
});
263+
it("encodes and decodes -100n", () => {
264+
const value = BigInt(-100);
265+
const encoded = encode(value, { extensionCodec });
266+
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
267+
});
269268

270-
it("encodes and decodes MAX_SAFE_INTEGER+1", () => {
271-
const value = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1);
272-
const encoded = encode(value, { extensionCodec });
273-
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
269+
it("encodes and decodes MAX_SAFE_INTEGER+1", () => {
270+
const value = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1);
271+
const encoded = encode(value, { extensionCodec });
272+
assert.deepStrictEqual(decode(encoded, { extensionCodec }), value);
273+
});
274274
});
275275

276276
context("native", () => {

0 commit comments

Comments
 (0)