From 75cd0a7081494a6f734213d0d54958ab548eeb86 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 10 May 2026 14:55:59 -0700 Subject: [PATCH 1/4] feat: allowUndefinedCustomEncoding option Allows extension codecs to handle undefined values instead of msgpack converting them to nil. Used by Gather's serialization framework to preserve {a: undefined} round-trip fidelity. --- src/Encoder.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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); From 7cd938c12de7e78527b24a6a669d35918f32b010 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 10 May 2026 14:56:10 -0700 Subject: [PATCH 2/4] chore: rename package to @gathertown/msgpack v3.1.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 648a79de4125bf08a4c11c26e3b6e2806a979d99 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 10 May 2026 15:21:31 -0700 Subject: [PATCH 3/4] test: add allowUndefinedCustomEncoding tests Ports the undefined round-trip test from the original patch PR and adds a new reentrancy test verifying that allowUndefinedCustomEncoding is propagated through clone() when the encoder's reentrance guard fires. --- test/ExtensionCodec.test.ts | 78 ++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/test/ExtensionCodec.test.ts b/test/ExtensionCodec.test.ts index 543171b..e494d55 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,82 @@ 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, + }); + + // encoder is declared here so the Box codec below can close over it. + // It is assigned after registration so the extensionCodec is fully set up first. + let encoder: Encoder; + + // 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 })), + }); + + encoder = new Encoder({ extensionCodec, allowUndefinedCustomEncoding: true }); + + 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(); From f04adc138e071995a1a1f781060649d2955b899d Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 10 May 2026 16:28:24 -0700 Subject: [PATCH 4/4] fix: Use const for encoder in reentrancy test The encoder variable was only assigned once; restructuring to declare it before the Box codec registration lets ESLint's prefer-const rule pass while keeping the test semantics identical (extensionCodec is a shared reference, so the Box codec registered afterward is still visible to the encoder at runtime). --- test/ExtensionCodec.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/ExtensionCodec.test.ts b/test/ExtensionCodec.test.ts index e494d55..3adb3e6 100644 --- a/test/ExtensionCodec.test.ts +++ b/test/ExtensionCodec.test.ts @@ -248,9 +248,7 @@ describe("ExtensionCodec", () => { decode: (_data: Uint8Array) => undefined, }); - // encoder is declared here so the Box codec below can close over it. - // It is assigned after registration so the extensionCodec is fully set up first. - let encoder: Encoder; + const encoder = new Encoder({ extensionCodec, allowUndefinedCustomEncoding: true }); // Box handler (type 0x2): calls encoder.encode() recursively to trigger clone() extensionCodec.register({ @@ -264,8 +262,6 @@ describe("ExtensionCodec", () => { decode: (data: Uint8Array) => new Box(decode(data, { extensionCodec })), }); - encoder = new Encoder({ extensionCodec, allowUndefinedCustomEncoding: true }); - it("propagates allowUndefinedCustomEncoding through clone()", () => { // Encoding Box(undefined): // outer encode() handles Box via type 0x2, which calls encoder.encode(undefined)