diff --git a/README.md b/README.md index c0f4dc0..748284e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ deepStrictEqual(decode(encoded), object); - [`EncoderOptions`](#encoderoptions) - [`decode(buffer: ArrayLike | BufferSource, options?: DecoderOptions): unknown`](#decodebuffer-arraylikenumber--buffersource-options-decoderoptions-unknown) - [`DecoderOptions`](#decoderoptions) + - [`decodeSingle(buffer: ArrayLike | BufferSource, options?: DecoderOptions): DecodeSingleResult`](#decodesinglebuffer-arraylikenumber--buffersource-options-decoderoptions-decodesingleresult) - [`decodeMulti(buffer: ArrayLike | BufferSource, options?: DecoderOptions): Generator`](#decodemultibuffer-arraylikenumber--buffersource-options-decoderoptions-generatorunknown-void-unknown) - [`decodeAsync(stream: ReadableStreamLike | BufferSource>, options?: DecoderOptions): Promise`](#decodeasyncstream-readablestreamlikearraylikenumber--buffersource-options-decoderoptions-promiseunknown) - [`decodeArrayStream(stream: ReadableStreamLike | BufferSource>, options?: DecoderOptions): AsyncIterable`](#decodearraystreamstream-readablestreamlikearraylikenumber--buffersource-options-decoderoptions-asynciterableunknown) @@ -129,7 +130,7 @@ It decodes `buffer` that includes a MessagePack-encoded object, and returns the `buffer` must be an array of bytes, which is typically `Uint8Array` or `ArrayBuffer`. `BufferSource` is defined as `ArrayBuffer | ArrayBufferView`. -The `buffer` must include a single encoded object. If the `buffer` includes extra bytes after an object or the `buffer` is empty, it throws `RangeError`. To decode `buffer` that includes multiple encoded objects, use `decodeMulti()` or `decodeMultiStream()` (recommended) instead. +The `buffer` must include a single encoded object. If the `buffer` includes extra bytes after an object or the `buffer` is empty, it throws `RangeError`. To decode a single object from a `buffer` that may have extra bytes after it, use `decodeSingle()` instead. To decode `buffer` that includes multiple encoded objects, use `decodeMulti()` or `decodeMultiStream()` (recommended) instead. for example: @@ -164,6 +165,24 @@ To skip UTF-8 decoding of strings, `rawStrings` can be set to `true`. In this ca You can use `max${Type}Length` to limit the length of each type decoded. +### `decodeSingle(buffer: ArrayLike | BufferSource, options?: DecoderOptions): DecodeSingleResult` + +It decodes `buffer` that includes a single MessagePack-encoded object, and returns the decoded object and the number of bytes consumed. Unlike `decode()`, it does not throw `RangeError` even if the `buffer` includes extra bytes after the object. + +`DecodeSingleResult` is defined as `{ value: unknown, bytesConsumed: number }`, where `value` is the decoded object and `bytesConsumed` is the position where the extra bytes begin in the `buffer`. + +This is useful to handle a protocol that mixes MessagePack-encoded objects and other data, for example `[msgpack object][opaque bytes][msgpack object]`: + +```typescript +import { decodeSingle } from "@msgpack/msgpack"; + +const buffer: Uint8Array; // includes a MessagePack-encoded object followed by other data + +const { value, bytesConsumed } = decodeSingle(buffer); +console.log(value); // the first object in the buffer +const rest = buffer.subarray(bytesConsumed); // the extra bytes after the object +``` + ### `decodeMulti(buffer: ArrayLike | BufferSource, options?: DecoderOptions): Generator` It decodes `buffer` that includes multiple MessagePack-encoded objects, and returns decoded objects as a generator. See also `decodeMultiStream()`, which is an asynchronous variant of this function. diff --git a/src/Decoder.ts b/src/Decoder.ts index 5b3c884..b0077af 100644 --- a/src/Decoder.ts +++ b/src/Decoder.ts @@ -80,6 +80,22 @@ export type DecoderOptions = Readonly< > & ContextOf; +/** + * The result of {@link Decoder#decodeSingle} and {@link decodeSingle}. + */ +export type DecodeSingleResult = { + /** + * The decoded object. + */ + value: unknown; + + /** + * The number of bytes consumed to decode {@link value}, + * which is the position where the extra bytes begin in the buffer. + */ + bytesConsumed: number; +}; + const STATE_ARRAY = "array"; const STATE_MAP_KEY = "map_key"; const STATE_MAP_VALUE = "map_value"; @@ -332,6 +348,33 @@ export class Decoder { } } + /** + * It decodes a single MessagePack object in a buffer and returns the decoded object and the + * number of bytes consumed. Unlike {@link decode}, it does not throw an error even if the + * buffer has extra bytes after the object. + * + * @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty. + * @throws {@link DecodeError} if the buffer contains invalid data. + */ + public decodeSingle(buffer: ArrayLike | ArrayBufferView | ArrayBufferLike): DecodeSingleResult { + if (this.entered) { + const instance = this.clone(); + return instance.decodeSingle(buffer); + } + + try { + this.entered = true; + + this.reinitializeState(); + this.setBuffer(buffer); + + const value = this.doDecodeSync(); + return { value, bytesConsumed: this.pos }; + } finally { + this.entered = false; + } + } + public *decodeMulti(buffer: ArrayLike | ArrayBufferView | ArrayBufferLike): Generator { if (this.entered) { const instance = this.clone(); diff --git a/src/decode.ts b/src/decode.ts index 3b8bf50..f5808c3 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -1,5 +1,5 @@ import { Decoder } from "./Decoder.ts"; -import type { DecoderOptions } from "./Decoder.ts"; +import type { DecoderOptions, DecodeSingleResult } from "./Decoder.ts"; import type { SplitUndefined } from "./context.ts"; /** @@ -19,6 +19,23 @@ export function decode( return decoder.decode(buffer); } +/** + * It decodes a single MessagePack object in a buffer and returns the decoded object and the + * number of bytes consumed. Unlike {@link decode}, it does not throw an error even if the + * buffer has extra bytes after the object, so it is useful to decode a buffer that contains + * non-MessagePack data after a MessagePack object. + * + * @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty. + * @throws {@link DecodeError} if the buffer contains invalid data. + */ +export function decodeSingle( + buffer: ArrayLike | ArrayBufferView | ArrayBufferLike, + options?: DecoderOptions>, +): DecodeSingleResult { + const decoder = new Decoder(options); + return decoder.decodeSingle(buffer); +} + /** * It decodes multiple MessagePack objects in a buffer. * This is corresponding to {@link decodeMultiStream}. diff --git a/src/index.ts b/src/index.ts index f4550e2..1ad5045 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,16 +3,16 @@ import { encode } from "./encode.ts"; export { encode }; -import { decode, decodeMulti } from "./decode.ts"; -export { decode, decodeMulti }; +import { decode, decodeSingle, decodeMulti } from "./decode.ts"; +export { decode, decodeSingle, decodeMulti }; import { decodeAsync, decodeArrayStream, decodeMultiStream } from "./decodeAsync.ts"; export { decodeAsync, decodeArrayStream, decodeMultiStream }; import { Decoder } from "./Decoder.ts"; export { Decoder }; -import type { DecoderOptions } from "./Decoder.ts"; -export type { DecoderOptions }; +import type { DecoderOptions, DecodeSingleResult } from "./Decoder.ts"; +export type { DecoderOptions, DecodeSingleResult }; import { DecodeError } from "./DecodeError.ts"; export { DecodeError }; diff --git a/test/decodeSingle.test.ts b/test/decodeSingle.test.ts new file mode 100644 index 0000000..acd853a --- /dev/null +++ b/test/decodeSingle.test.ts @@ -0,0 +1,65 @@ +import assert from "assert"; +import { encode, decodeSingle, Decoder } from "../src/index.ts"; + +describe("decodeSingle", () => { + it("decodes a single object and returns the number of bytes consumed", () => { + const item = { name: "foo" }; + const encoded = encode(item); + + const result = decodeSingle(encoded); + + assert.deepStrictEqual(result.value, item); + assert.strictEqual(result.bytesConsumed, encoded.byteLength); + }); + + it("does not throw an error even if the buffer has extra bytes after the object", () => { + // simulates a framed buffer: [msgpack object][opaque bytes][msgpack object] + const firstItem = { type: "binary", size: 4 }; + const secondItem = [1, 2, 3]; + const opaqueBytes = Uint8Array.from([0xc1, 0xff, 0x00, 0xc1]); // 0xc1 is never used in MessagePack + + const encodedFirst = encode(firstItem); + const encodedSecond = encode(secondItem); + const buffer = new Uint8Array(encodedFirst.byteLength + opaqueBytes.byteLength + encodedSecond.byteLength); + buffer.set(encodedFirst); + buffer.set(opaqueBytes, encodedFirst.byteLength); + buffer.set(encodedSecond, encodedFirst.byteLength + opaqueBytes.byteLength); + + const first = decodeSingle(buffer); + assert.deepStrictEqual(first.value, firstItem); + assert.strictEqual(first.bytesConsumed, encodedFirst.byteLength); + + // skip the opaque bytes, whose size is known from the first object + const second = decodeSingle(buffer.subarray(first.bytesConsumed + opaqueBytes.byteLength)); + assert.deepStrictEqual(second.value, secondItem); + assert.strictEqual(second.bytesConsumed, encodedSecond.byteLength); + }); + + it("throws RangeError if the buffer is empty", () => { + assert.throws(() => { + decodeSingle(new Uint8Array(0)); + }, RangeError); + }); + + it("throws RangeError if the buffer is truncated", () => { + const encoded = encode("foobar"); + assert.throws(() => { + decodeSingle(encoded.subarray(0, encoded.byteLength - 1)); + }, RangeError); + }); + + it("is also available as Decoder#decodeSingle for reusing an instance", () => { + const items = ["foo", 10]; + const encoded = encode(items[0]); + const bufferWithExtraBytes = new Uint8Array(encoded.byteLength + encode(items[1]).byteLength); + bufferWithExtraBytes.set(encoded); + bufferWithExtraBytes.set(encode(items[1]), encoded.byteLength); + + const decoder = new Decoder(); + for (let i = 0; i < 3; i++) { + const result = decoder.decodeSingle(bufferWithExtraBytes); + assert.deepStrictEqual(result.value, items[0]); + assert.strictEqual(result.bytesConsumed, encoded.byteLength); + } + }); +});