diff --git a/package.json b/package.json index 5441842..389b0c3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@msgpack/msgpack", + "name": "@gathertown/msgpack", "version": "3.1.3", "description": "MessagePack for ECMA-262/JavaScript/TypeScript", "author": "The MessagePack community", diff --git a/src/Encoder.ts b/src/Encoder.ts index b047c1d..8717672 100644 --- a/src/Encoder.ts +++ b/src/Encoder.ts @@ -61,6 +61,14 @@ export type EncoderOptions = Partial< */ ignoreUndefined: boolean; + /** + * If `true`, undefineds are not handled by the library and are instead + * made available to extension codecs + * + * Defaults to `false` + */ + allowUndefinedCustomEncoding: boolean; + /** * If `true`, integer numbers are encoded as floating point numbers, * with the `forceFloat32` option taken into account. @@ -81,6 +89,7 @@ export class Encoder { private readonly sortKeys: boolean; private readonly forceFloat32: boolean; private readonly ignoreUndefined: boolean; + private readonly allowUndefinedCustomEncoding: boolean; private readonly forceIntegerToFloat: boolean; private pos: number; @@ -99,6 +108,7 @@ export class Encoder { this.sortKeys = options?.sortKeys ?? false; this.forceFloat32 = options?.forceFloat32 ?? false; this.ignoreUndefined = options?.ignoreUndefined ?? false; + this.allowUndefinedCustomEncoding = options?.allowUndefinedCustomEncoding ?? false; this.forceIntegerToFloat = options?.forceIntegerToFloat ?? false; this.pos = 0; @@ -119,6 +129,7 @@ export class Encoder { sortKeys: this.sortKeys, forceFloat32: this.forceFloat32, ignoreUndefined: this.ignoreUndefined, + allowUndefinedCustomEncoding: this.allowUndefinedCustomEncoding, forceIntegerToFloat: this.forceIntegerToFloat, } as any); } @@ -174,7 +185,8 @@ export class Encoder { throw new Error(`Too deep objects in depth ${depth}`); } - if (object == null) { + const objectIsNil = this.allowUndefinedCustomEncoding ? object === null : object == null; + if (objectIsNil) { this.encodeNil(); } else if (typeof object === "boolean") { this.encodeBoolean(object); diff --git a/test/ExtensionCodec.test.ts b/test/ExtensionCodec.test.ts index 543171b..3adb3e6 100644 --- a/test/ExtensionCodec.test.ts +++ b/test/ExtensionCodec.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; import util from "util"; -import { encode, decode, ExtensionCodec, decodeAsync } from "../src/index.ts"; +import { encode, decode, Encoder, ExtensionCodec, decodeAsync } from "../src/index.ts"; describe("ExtensionCodec", () => { context("timestamp", () => { @@ -202,6 +202,78 @@ describe("ExtensionCodec", () => { }); }); + context("allowUndefinedCustomEncoding", () => { + const extensionCodec = new ExtensionCodec(); + + extensionCodec.register({ + type: 0x1, + encode: (object: unknown): Uint8Array | null => { + if (object === undefined) { + return new Uint8Array(0); + } + return null; + }, + decode: (data: Uint8Array) => { + if (data.length === 0) { + return undefined; + } + throw new Error("invalid data"); + }, + }); + + it("encodes and decodes undefined (synchronously)", () => { + const encoded = encode([undefined], { extensionCodec, allowUndefinedCustomEncoding: true }); + assert.deepStrictEqual(decode(encoded, { extensionCodec }), [undefined]); + }); + }); + + context("allowUndefinedCustomEncoding with clone() propagation (reentrancy)", () => { + // Box is a wrapper type whose extension codec calls encoder.encode() recursively, + // forcing the encoder's reentrancy guard to invoke clone(). + class Box { + constructor(public readonly value: unknown) {} + } + + const extensionCodec = new ExtensionCodec(); + + // Undefined handler (type 0x1) + extensionCodec.register({ + type: 0x1, + encode: (object: unknown): Uint8Array | null => { + if (object === undefined) { + return new Uint8Array(0); + } + return null; + }, + decode: (_data: Uint8Array) => undefined, + }); + + const encoder = new Encoder({ extensionCodec, allowUndefinedCustomEncoding: true }); + + // Box handler (type 0x2): calls encoder.encode() recursively to trigger clone() + extensionCodec.register({ + type: 0x2, + encode: (object: unknown): Uint8Array | null => { + if (object instanceof Box) { + return encoder.encode(object.value); + } + return null; + }, + decode: (data: Uint8Array) => new Box(decode(data, { extensionCodec })), + }); + + it("propagates allowUndefinedCustomEncoding through clone()", () => { + // Encoding Box(undefined): + // outer encode() handles Box via type 0x2, which calls encoder.encode(undefined) + // encoder is already entered, so clone() fires — the clone must carry + // allowUndefinedCustomEncoding or undefined would become nil instead of + // reaching the type 0x1 codec. + const encoded = encoder.encode(new Box(undefined)); + const decoded = decode(encoded, { extensionCodec }) as Box; + assert.strictEqual(decoded.value, undefined); + }); + }); + context("custom extensions with alignment", () => { const extensionCodec = new ExtensionCodec();