diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8258e0..5ff5e8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [3.2.0] - 2026-09-19
+
+Optional second-argument unpack option `{ lazy: true }` wraps maps and arrays
+as accessors so nested values are not converted until they are read. See `#40`.
+
+### Added
+
+- `unpack(buf, { lazy: true })` keeps the decoder zone alive and returns maps
+ as objects with accessor own-properties and arrays as array-likes with
+ indexed accessors (`length`, `in`, `Object.keys`). Nested maps and arrays
+ stay lazy until a property is read.
+- `toJSON` and `util.inspect.custom` materialize through the eager converter,
+ so `JSON.stringify` and `util.inspect` match eager unpack. `pack()` of a
+ lazy value also round-trips because it calls `toJSON`.
+- Primitives, incomplete buffers, trailing `bytes_remaining`, and the DoS
+ limits are unchanged. `__proto__` / `constructor` stay own properties.
+- Lazy unpack copies the input before decode so str/bin do not alias the
+ caller's Buffer. Transferring that Buffer after unpack cannot dangle
+ later property reads.
+
## [3.1.0] - 2026-09-19
Optional second-argument pack hints force a MessagePack wire type or family
@@ -108,7 +128,8 @@ GitHub Actions tests Node 18/20/22 on Ubuntu, macOS, and Windows 2022.
- Pack throw paths free or return pooled sbuffers on every exit.
- msgpack-c c-7.0.2 includes unpacker buffer-expansion overflow checks.
-[Unreleased]: https://github.com/msgpack/msgpack-node/compare/v3.1.0...HEAD
+[Unreleased]: https://github.com/msgpack/msgpack-node/compare/v3.2.0...HEAD
+[3.2.0]: https://github.com/msgpack/msgpack-node/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/msgpack/msgpack-node/compare/v3.0.0...v3.1.0
[3.0.0]: https://github.com/msgpack/msgpack-node/compare/e04c9b55f98d64512174d6e859b8294b729659a2...HEAD
[2.0.0]: https://github.com/msgpack/msgpack-node/commit/e04c9b55f98d64512174d6e859b8294b729659a2
diff --git a/COVERAGE.md b/COVERAGE.md
index c95e0f8..4ee8128 100644
--- a/COVERAGE.md
+++ b/COVERAGE.md
@@ -1,4 +1,4 @@
-# Coverage — msgpack 3.1.0
+# Coverage — msgpack 3.2.0
`npm run coverage` runs both halves and fails the build under 95%.
@@ -8,9 +8,9 @@
| `lib/` + `bin/` (c8) | branches | **100%** | ≥ 95% |
| `lib/` + `bin/` (c8) | functions | **100%** | ≥ 95% |
| `lib/` + `bin/` (c8) | lines | **100%** | ≥ 95% |
-| `src/` (gcovr) | lines | **95.2%** (902/947) | ≥ 95% |
-| `src/` (gcovr) | branches | **95.4%** (836/876) | ≥ 95% |
-| `src/` (gcovr) | functions | 100% (59/59) | — |
+| `src/` (gcovr) | lines | **95.7%** (1002/1047) | ≥ 95% |
+| `src/` (gcovr) | branches | **95.5%** (976/1022) | ≥ 95% |
+| `src/` (gcovr) | functions | 100% (66/66) | — |
`deps/` is excluded from the native report; the vendored msgpack-c is not our
code. `build/` is rebuilt without instrumentation at the end of
@@ -146,6 +146,14 @@ gcovr --root . --filter src/ --exclude deps/ --no-markers --txt-metric branch --
`kMaxPackDepth`) are marked `GCOVR_EXCL_*`, not deleted. Native overall
stays above the 95% gate (`pack_hints.inc` itself is 91% branches because
switch `default:` edges sit on the same line as covered cases).
+- `test/lazy.test.js` — `#40` `unpack(buf, { lazy: true })`: one-arg eager
+ identity, nested `o.c[1]` without reading siblings, `__proto__` /
+ `constructor` as own properties, oversized headers still throw, incomplete
+ buffers still return `null`, `toJSON` / `JSON.stringify` / `util.inspect`
+ match eager unpack, nested BigInt, non-object second args, toJSON
+ `this` checks, and str/bin reads after the caller Buffer is transferred.
+ Lazy OOM / empty-Maybe / ObjectTemplate-failure / CopyBuffer-failure arms
+ are marked `GCOVR_EXCL_*`, not deleted.
- `test/cli.test.js` (12 tests) — the exit-1 paths of both CLIs: invalid JSON,
empty stdin, a pack rejection reachable from real JSON, an unparseable byte,
an oversized header, incomplete input both alone and after a good frame, and
diff --git a/README.md b/README.md
index 67a737e..ec017e8 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,10 @@
and de-serializes JavaScript values with [MessagePack](https://msgpack.org).
Packed output is a `Buffer` and is typically much smaller than JSON.
-Version 3.1 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks
-64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, and accepts
-optional pack type/family hints. See [`SECURITY.md`](SECURITY.md).
+Version 3.2 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks
+64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, accepts
+optional pack type/family hints, and can unpack maps and arrays lazily
+(`unpack(buf, { lazy: true })`). See [`SECURITY.md`](SECURITY.md).
### Usage
@@ -79,6 +80,17 @@ is that same `bigint`.
`unpack.bytes_remaining` is the number of unused trailing bytes after the last
successful (or attempted) unpack. Stream uses that to splice leftover data.
+`unpack(buf, { lazy: true })` wraps maps as objects with accessor
+own-properties and arrays as array-likes with indexed accessors. Nested
+values are not converted until they are read, which is useful for large
+payloads when only a few keys are needed. The decoder copies `buf` so later
+reads do not depend on the caller's backing store (transfer / detach is
+safe). `JSON.stringify` and `util.inspect` materialize via `toJSON` /
+`inspect.custom`. Lazy arrays are not real `Array`s (`Array.isArray` is
+false); `pack()` still round-trips them because it calls `toJSON`.
+Primitives unpack eagerly even when `lazy` is set. `__proto__` and
+`constructor` keys stay own properties, same as eager unpack.
+
### Pack type hints (3.1)
`pack(value, options)` takes an optional last-argument options object when
diff --git a/index.d.ts b/index.d.ts
index d253e74..d0faf3f 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,4 +1,4 @@
-// Type definitions for msgpack 3.1.0
+// Type definitions for msgpack 3.2.0
// Project: https://github.com/msgpack/msgpack-node
///
@@ -74,8 +74,15 @@ export function pack(...values: any[]): Buffer;
* Returns `null` when the buffer holds an incomplete value, in which case
* `unpack.bytes_remaining` equals `buf.length`. Throws on malformed input or
* when a container/string/bin header exceeds the decoder's limits.
+ *
+ * Pass `{ lazy: true }` to wrap maps as objects with accessor own-properties
+ * and arrays as array-likes with indexed accessors. Nested values are not
+ * converted until read. `JSON.stringify` and `util.inspect` materialize via
+ * `toJSON` / `inspect.custom`. Lazy arrays are not real `Array`s
+ * (`Array.isArray` is false); `pack()` still round-trips them because it
+ * calls `toJSON`. Primitives unpack eagerly even when `lazy` is set.
*/
-export function unpack(buf: Buffer): any;
+export function unpack(buf: Buffer, opts?: { lazy?: boolean }): any;
export namespace unpack {
/**
diff --git a/lib/msgpack.js b/lib/msgpack.js
index a8ec5ef..81fae7f 100644
--- a/lib/msgpack.js
+++ b/lib/msgpack.js
@@ -18,8 +18,8 @@ function pack() {
return bpack.apply(null, arguments);
}
-function unpack(buf) {
- const result = rawUnpack(buf);
+function unpack(buf, opts) {
+ const result = arguments.length < 2 ? rawUnpack(buf) : rawUnpack(buf, opts);
unpack.bytes_remaining = mpBindings.bytesRemaining();
return result;
}
diff --git a/package-lock.json b/package-lock.json
index 5ce342f..bc2946c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "msgpack",
- "version": "3.1.0",
+ "version": "3.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "msgpack",
- "version": "3.1.0",
+ "version": "3.2.0",
"license": "BSD-3-Clause",
"dependencies": {
"nan": "^2.23.1"
diff --git a/package.json b/package.json
index 37d0d62..88a2a1e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "msgpack",
"description": "A space-efficient object serialization library for Node.js",
- "version": "3.1.0",
+ "version": "3.2.0",
"homepage": "https://github.com/msgpack/msgpack-node",
"author": "Peter Griess ",
"contributors": [
@@ -34,7 +34,7 @@
"nan": "^2.23.1"
},
"scripts": {
- "test": "node --test test/bigint.test.js test/cli.test.js test/coverage-native.test.js test/msgpack.test.js test/pack-hints.test.js test/regression.test.js test/security.test.js test/worker.test.js",
+ "test": "node --test test/bigint.test.js test/cli.test.js test/coverage-native.test.js test/lazy.test.js test/msgpack.test.js test/pack-hints.test.js test/regression.test.js test/security.test.js test/worker.test.js",
"bench": "node test/benchmark/benchmark.js",
"rebuild": "node-gyp rebuild",
"coverage": "npm run coverage:js && npm run coverage:native",
diff --git a/src/msgpack.cc b/src/msgpack.cc
index 0836489..79f92cb 100644
--- a/src/msgpack.cc
+++ b/src/msgpack.cc
@@ -677,6 +677,260 @@ static v8::Local MsgpackToJs(const msgpack_object* mo) {
}
}
+/*
+ * Lazy unpack: keep the msgpack zone (and a session-owned copy of the source
+ * bytes) alive, and wrap maps/arrays as JS objects whose values are accessors.
+ * Nested containers are not converted until a property is read. toJSON /
+ * inspect.custom materialize through MsgpackToJs so JSON.stringify and
+ * util.inspect match eager unpack. The copy is required because msgpack-c
+ * aliases str/bin into the input; a Persistent on the caller's Buffer does
+ * not survive ArrayBuffer transfer.
+ */
+class LazySession : public Nan::ObjectWrap {
+ public:
+ msgpack_unpacked unpacked;
+ Nan::Persistent buffer;
+
+ static NAN_METHOD(New) {
+ LazySession* session = new LazySession();
+ msgpack_unpacked_init(&session->unpacked);
+ session->Wrap(info.This());
+ info.GetReturnValue().Set(info.This());
+ }
+
+ static v8::Local Create(v8::Local buf,
+ msgpack_unpacked* src) {
+ v8::Local cons = Nan::New(ctor);
+ v8::Local inst = Nan::NewInstance(cons).ToLocalChecked();
+ LazySession* session = Nan::ObjectWrap::Unwrap(inst);
+ msgpack_unpacked_destroy(&session->unpacked);
+ session->unpacked = *src;
+ src->zone = NULL;
+ session->buffer.Reset(buf);
+ return inst;
+ }
+
+ ~LazySession() {
+ msgpack_unpacked_destroy(&unpacked);
+ buffer.Reset();
+ }
+
+ static thread_local Nan::Persistent ctor;
+
+ private:
+ LazySession() {}
+};
+
+thread_local Nan::Persistent LazySession::ctor;
+
+static thread_local Nan::Persistent lazy_tojson_fn;
+static thread_local Nan::Persistent lazy_array_tmpl;
+static thread_local Nan::Persistent lazy_map_tmpl;
+static thread_local Nan::Persistent lazy_session_key;
+static thread_local Nan::Persistent lazy_mo_key;
+
+static void AttachLazy(v8::Local obj,
+ v8::Local session,
+ const msgpack_object* mo) {
+ Nan::SetPrivate(obj, Nan::New(lazy_session_key), session);
+ Nan::SetPrivate(obj, Nan::New(lazy_mo_key),
+ Nan::New(const_cast(mo)));
+}
+
+static v8::Local LazySessionOf(v8::Local obj) {
+ Nan::MaybeLocal v = Nan::GetPrivate(obj, Nan::New(lazy_session_key));
+ /* GCOVR_EXCL_BR_START: only missing if a getter is applied to a foreign object. */
+ if (v.IsEmpty() || !v.ToLocalChecked()->IsObject()) {
+ return Nan::New();
+ }
+ /* GCOVR_EXCL_BR_STOP */
+ return v.ToLocalChecked().As();
+}
+
+static const msgpack_object* LazyMoOf(v8::Local obj) {
+ Nan::MaybeLocal v = Nan::GetPrivate(obj, Nan::New(lazy_mo_key));
+ /* GCOVR_EXCL_BR_START: same as LazySessionOf. */
+ if (v.IsEmpty() || !v.ToLocalChecked()->IsExternal()) {
+ return NULL;
+ }
+ /* GCOVR_EXCL_BR_STOP */
+ return static_cast(
+ v.ToLocalChecked().As()->Value());
+}
+
+static v8::Local MsgpackToJsLazy(const msgpack_object* mo,
+ v8::Local session);
+
+static void InstallLazyMethods(v8::Local obj) {
+ v8::Local fn = Nan::New(lazy_tojson_fn);
+ v8::PropertyAttribute hidden =
+ static_cast(v8::ReadOnly | v8::DontEnum);
+ Nan::DefineOwnProperty(obj, Nan::New("toJSON").ToLocalChecked(), fn, hidden);
+ v8::Local inspect = v8::Symbol::For(
+ v8::Isolate::GetCurrent(),
+ Nan::New("nodejs.util.inspect.custom").ToLocalChecked());
+ obj->DefineOwnProperty(Nan::GetCurrentContext(), inspect, fn, hidden)
+ .FromMaybe(false);
+}
+
+NAN_METHOD(LazyToJSON) {
+ /* NAN_METHOD is sloppy: null/undefined This is boxed to the global. */
+ if (!info.This()->IsObject()) { /* GCOVR_EXCL_BR_LINE */
+ return Nan::ThrowTypeError("invalid lazy object"); /* GCOVR_EXCL_LINE */
+ }
+ v8::Local self = info.This();
+ const msgpack_object* mo = LazyMoOf(self);
+ if (mo == NULL) {
+ return Nan::ThrowTypeError("invalid lazy object");
+ }
+ try {
+ info.GetReturnValue().Set(MsgpackToJs(mo));
+ } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJs throws nothing else */
+ Nan::ThrowError(e.value());
+ }
+}
+
+static v8::Local WrapLazyArray(const msgpack_object* mo,
+ v8::Local session) {
+ v8::Local obj =
+ Nan::New(lazy_array_tmpl)->NewInstance(Nan::GetCurrentContext()).ToLocalChecked();
+ AttachLazy(obj, session, mo);
+ Nan::DefineOwnProperty(
+ obj,
+ Nan::New("length").ToLocalChecked(),
+ Nan::New(static_cast(mo->via.array.size)),
+ static_cast(v8::ReadOnly | v8::DontEnum));
+ InstallLazyMethods(obj);
+ return obj;
+}
+
+static void LazyMapNameGetter(v8::Local /*property*/,
+ const v8::PropertyCallbackInfo& info) {
+ const msgpack_object* val =
+ static_cast(info.Data().As()->Value());
+ v8::Local session = LazySessionOf(info.Holder());
+ try {
+ info.GetReturnValue().Set(MsgpackToJsLazy(val, session));
+ } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJsLazy throws nothing else */
+ Nan::ThrowError(e.value());
+ }
+}
+
+static v8::Local WrapLazyMap(const msgpack_object* mo,
+ v8::Local session) {
+ v8::Local ctx = Nan::GetCurrentContext();
+ v8::Local obj =
+ Nan::New(lazy_map_tmpl)->NewInstance(ctx).ToLocalChecked();
+ /* ObjectTemplate instances get a hidden prototype. Eager maps are
+ ordinary objects whose [[Prototype]] is Object.prototype. */
+ Nan::SetPrototype(obj, Nan::New()->GetPrototype());
+ AttachLazy(obj, session, mo);
+ for (uint32_t i = 0; i < mo->via.map.size; i++) {
+ const msgpack_object_kv* kv = &mo->via.map.ptr[i];
+ v8::Local key = MsgpackToJs(&kv->key);
+ Nan::MaybeLocal name = Nan::To(key);
+ /* GCOVR_EXCL_BR_START: same as eager map keys. */
+ if (name.IsEmpty()) {
+ throw MsgpackException(Error("cannot unpack map key"));
+ }
+ /* GCOVR_EXCL_BR_STOP */
+ if (!obj->SetNativeDataProperty(
+ ctx,
+ name.ToLocalChecked(),
+ LazyMapNameGetter,
+ 0,
+ Nan::New(const_cast(&kv->val)))
+ .FromMaybe(false)) { /* GCOVR_EXCL_BR_LINE: OOM / rejected name */
+ throw MsgpackException(Error("cannot unpack map key")); /* GCOVR_EXCL_LINE */
+ }
+ }
+ InstallLazyMethods(obj);
+ return obj;
+}
+
+static v8::Local MsgpackToJsLazy(const msgpack_object* mo,
+ v8::Local session) {
+ switch (mo->type) {
+ case MSGPACK_OBJECT_ARRAY:
+ return WrapLazyArray(mo, session);
+ case MSGPACK_OBJECT_MAP:
+ return WrapLazyMap(mo, session);
+ default:
+ return MsgpackToJs(mo);
+ }
+}
+
+static void LazyIndexGet(uint32_t index,
+ const v8::PropertyCallbackInfo& info) {
+ const msgpack_object* mo = LazyMoOf(info.Holder());
+ if (mo == NULL || mo->type != MSGPACK_OBJECT_ARRAY || index >= mo->via.array.size) {
+ return;
+ }
+ v8::Local session = LazySessionOf(info.Holder());
+ try {
+ info.GetReturnValue().Set(MsgpackToJsLazy(&mo->via.array.ptr[index], session));
+ } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJsLazy throws nothing else */
+ Nan::ThrowError(e.value());
+ }
+}
+
+static void LazyIndexQuery(uint32_t index,
+ const v8::PropertyCallbackInfo& info) {
+ const msgpack_object* mo = LazyMoOf(info.Holder());
+ if (mo == NULL || mo->type != MSGPACK_OBJECT_ARRAY || index >= mo->via.array.size) {
+ return;
+ }
+ info.GetReturnValue().Set(v8::None);
+}
+
+static void LazyIndexEnum(const v8::PropertyCallbackInfo& info) {
+ const msgpack_object* mo = LazyMoOf(info.Holder());
+ uint32_t n = 0;
+ if (mo != NULL && mo->type == MSGPACK_OBJECT_ARRAY) {
+ n = static_cast(mo->via.array.size);
+ }
+ v8::Local names = Nan::New(n);
+ for (uint32_t i = 0; i < n; i++) {
+ Nan::Set(names, i, Nan::New(i));
+ }
+ info.GetReturnValue().Set(names);
+}
+
+static void InitLazy() {
+ v8::Isolate* isolate = v8::Isolate::GetCurrent();
+
+ v8::Local stpl = Nan::New(LazySession::New);
+ stpl->SetClassName(Nan::New("MsgpackLazySession").ToLocalChecked());
+ stpl->InstanceTemplate()->SetInternalFieldCount(1);
+ LazySession::ctor.Reset(Nan::GetFunction(stpl).ToLocalChecked());
+
+ lazy_tojson_fn.Reset(
+ Nan::GetFunction(Nan::New(LazyToJSON)).ToLocalChecked());
+
+ v8::Local arr = v8::ObjectTemplate::New(isolate);
+ arr->SetIndexedPropertyHandler(LazyIndexGet, 0, LazyIndexQuery, 0, LazyIndexEnum);
+ lazy_array_tmpl.Reset(arr);
+
+ v8::Local map = v8::ObjectTemplate::New(isolate);
+ map->SetIndexedPropertyHandler(LazyIndexGet, 0, LazyIndexQuery, 0, LazyIndexEnum);
+ lazy_map_tmpl.Reset(map);
+
+ lazy_session_key.Reset(Nan::New("msgpack:lazySession").ToLocalChecked());
+ lazy_mo_key.Reset(Nan::New("msgpack:lazyMo").ToLocalChecked());
+}
+
+static bool UnpackLazyRequested(const Nan::FunctionCallbackInfo& info) {
+ if (info.Length() < 2 || !info[1]->IsObject() || info[1]->IsArray()) {
+ return false;
+ }
+ Nan::MaybeLocal maybe =
+ Nan::Get(info[1].As(), Nan::New("lazy").ToLocalChecked());
+ if (maybe.IsEmpty()) { /* GCOVR_EXCL_LINE */
+ return false; /* GCOVR_EXCL_LINE */
+ }
+ return maybe.ToLocalChecked()->IsTrue();
+}
+
struct SbufPool {
msgpack_sbuffer* list[kSbufferPoolMax];
size_t length;
@@ -824,6 +1078,26 @@ NAN_METHOD(Unpack) {
return Nan::ThrowError("Encountered error unpacking buffer");
}
+ /* Copy before unpack_next so via.str/via.bin alias session-owned bytes.
+ * Nan::Persistent on the caller's Buffer does not keep the backing store
+ * through structuredClone / postMessage transfer (CWE-416). */
+ if (UnpackLazyRequested(info)) {
+ /* GCOVR_EXCL_BR_START: node Buffers are smaller than UINT32_MAX. */
+ if (len > static_cast(UINT32_MAX)) {
+ return Nan::ThrowError("Error copying buffer");
+ }
+ /* GCOVR_EXCL_BR_STOP */
+ Nan::MaybeLocal copied =
+ Nan::CopyBuffer(data, static_cast(len));
+ /* GCOVR_EXCL_BR_START: CopyBuffer fails only when V8 is out of memory. */
+ if (copied.IsEmpty()) {
+ return Nan::ThrowError("Error copying buffer");
+ }
+ /* GCOVR_EXCL_BR_STOP */
+ buf = copied.ToLocalChecked();
+ data = node::Buffer::Data(buf);
+ }
+
msgpack_unpacked result;
msgpack_unpacked_init(&result);
size_t off = 0;
@@ -840,11 +1114,20 @@ NAN_METHOD(Unpack) {
* msgpack_unpack_next never returns EXTRA_BYTES. */
if (ret == MSGPACK_UNPACK_SUCCESS || ret == MSGPACK_UNPACK_EXTRA_BYTES) { /* GCOVR_EXCL_BR_LINE */
try {
- v8::Local v = MsgpackToJs(&result.data);
- msgpack_unpacked_destroy(&result);
+ v8::Local v;
+ if (UnpackLazyRequested(info) &&
+ (result.data.type == MSGPACK_OBJECT_ARRAY ||
+ result.data.type == MSGPACK_OBJECT_MAP)) {
+ v8::Local session = LazySession::Create(buf, &result);
+ LazySession* hold = Nan::ObjectWrap::Unwrap(session);
+ v = MsgpackToJsLazy(&hold->unpacked.data, session);
+ } else {
+ v = MsgpackToJs(&result.data);
+ msgpack_unpacked_destroy(&result);
+ }
info.GetReturnValue().Set(v);
return;
- } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJs throws nothing else */
+ } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: convert throws nothing else */
msgpack_unpacked_destroy(&result);
return Nan::ThrowError(e.value());
}
@@ -863,6 +1146,7 @@ NAN_METHOD(Unpack) {
NAN_MODULE_INIT(Init) {
stack_key.Reset(Nan::New("_msgpack_stack").ToLocalChecked());
+ InitLazy();
Nan::Set(target, Nan::New("pack").ToLocalChecked(),
Nan::GetFunction(Nan::New(Pack)).ToLocalChecked());
Nan::Set(target, Nan::New("unpack").ToLocalChecked(),
diff --git a/test/lazy.test.js b/test/lazy.test.js
new file mode 100644
index 0000000..6af418d
--- /dev/null
+++ b/test/lazy.test.js
@@ -0,0 +1,209 @@
+'use strict';
+
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const util = require('node:util');
+const msgpack = require('../lib/msgpack');
+
+describe('unpack({ lazy: true })', () => {
+ it('matches the issue #40 example', () => {
+ const o = msgpack.unpack(msgpack.pack({ a: 1, b: 2, c: [1, 2, 3] }), { lazy: true });
+ assert.equal(o.a, 1);
+ assert.equal(o.c[1], 2);
+ assert.deepEqual(JSON.parse(JSON.stringify(o)), { a: 1, b: 2, c: [1, 2, 3] });
+ });
+
+ it('does not convert nested values until they are read', () => {
+ const o = msgpack.unpack(msgpack.pack({ a: { b: 1 }, c: [1, 2, 3] }), { lazy: true });
+ assert.equal(Array.isArray(o.c), false);
+ assert.equal(o.c.length, 3);
+ assert.equal(o.a.b, 1);
+ assert.equal(typeof o.a.toJSON, 'function');
+ });
+
+ it('materializes via toJSON for JSON.stringify and pack()', () => {
+ const src = { a: 1, b: 2, c: [1, 2, 3] };
+ const o = msgpack.unpack(msgpack.pack(src), { lazy: true });
+ assert.deepEqual(msgpack.unpack(msgpack.pack(o)), src);
+ assert.match(util.inspect(o), /a: 1/);
+ });
+
+ it('unpacks top-level arrays as array-likes', () => {
+ const o = msgpack.unpack(msgpack.pack([1, 2, 3]), { lazy: true });
+ assert.equal(Array.isArray(o), false);
+ assert.equal(o.length, 3);
+ assert.equal(o[0], 1);
+ assert.equal(o[2], 3);
+ assert.equal(o[99], undefined);
+ assert.equal(1 in o, true);
+ assert.equal(99 in o, false);
+ assert.deepEqual(Object.keys(o), ['0', '1', '2']);
+ assert.deepEqual(JSON.parse(JSON.stringify(o)), [1, 2, 3]);
+ });
+
+ it('unpacks empty maps and arrays', () => {
+ assert.deepEqual(
+ JSON.parse(JSON.stringify(msgpack.unpack(msgpack.pack({}), { lazy: true }))),
+ {}
+ );
+ assert.deepEqual(
+ JSON.parse(JSON.stringify(msgpack.unpack(msgpack.pack([]), { lazy: true }))),
+ []
+ );
+ });
+
+ it('still unpacks primitives eagerly', () => {
+ assert.equal(msgpack.unpack(msgpack.pack(1), { lazy: true }), 1);
+ assert.equal(msgpack.unpack(msgpack.pack(null), { lazy: true }), null);
+ assert.equal(msgpack.unpack(msgpack.pack(true), { lazy: true }), true);
+ assert.equal(msgpack.unpack(msgpack.pack('hi'), { lazy: true }), 'hi');
+ });
+
+ it('keeps bigint, Buffer, and integer keys', () => {
+ const big = 18446464814936021036n;
+ const o = msgpack.unpack(
+ msgpack.pack({ n: big, b: Buffer.from('hi'), 1: 'a' }),
+ { lazy: true }
+ );
+ assert.equal(o.n, big);
+ assert.ok(Buffer.isBuffer(o.b));
+ assert.equal(o.b.toString(), 'hi');
+ assert.equal(o[1], 'a');
+ });
+
+ it('does not treat a second-arg array as options', () => {
+ const packed = msgpack.pack({ a: 1 });
+ const o = msgpack.unpack(packed, [{ lazy: true }]);
+ assert.deepEqual(o, { a: 1 });
+ assert.equal(typeof o.toJSON, 'undefined');
+ });
+
+ it('does not treat a non-object second argument as options', () => {
+ const packed = msgpack.pack({ a: 1 });
+ const o = msgpack.unpack(packed, 0);
+ assert.deepEqual(o, { a: 1 });
+ assert.equal(typeof o.toJSON, 'undefined');
+ });
+
+ it('ignores lazy when it is not boolean true', () => {
+ const o = msgpack.unpack(msgpack.pack({ a: 1 }), { lazy: 1 });
+ assert.deepEqual(o, { a: 1 });
+ assert.equal(typeof o.toJSON, 'undefined');
+ });
+
+ it('returns null for incomplete input even with lazy', () => {
+ assert.equal(msgpack.unpack(Buffer.from([0x81]), { lazy: true }), null);
+ assert.equal(msgpack.unpack.bytes_remaining, 1);
+ });
+
+ it('still reports trailing bytes after a lazy unpack', () => {
+ const first = msgpack.pack({ a: 1 });
+ const second = msgpack.pack(2);
+ const buf = Buffer.concat([first, second]);
+ const o = msgpack.unpack(buf, { lazy: true });
+ assert.equal(o.a, 1);
+ assert.equal(msgpack.unpack.bytes_remaining, second.length);
+ });
+
+ it('throws on nested ext only when the value is read', () => {
+ /* map1, str "x", fixext1 type 0 data 0 */
+ const wire = Buffer.from([0x81, 0xa1, 0x78, 0xd4, 0x00, 0x00]);
+ const o = msgpack.unpack(wire, { lazy: true });
+ assert.throws(() => o.x, /cannot unpack ext type/);
+ assert.throws(() => JSON.stringify(o), /cannot unpack ext type/);
+ });
+
+ it('throws on nested ext in a lazy array when the index is read', () => {
+ /* array1 of fixext1 */
+ const wire = Buffer.from([0x91, 0xd4, 0x00, 0x00]);
+ const o = msgpack.unpack(wire, { lazy: true });
+ assert.throws(() => o[0], /cannot unpack ext type/);
+ assert.throws(() => JSON.stringify(o), /cannot unpack ext type/);
+ });
+
+ it('does not pollute Object.prototype via a lazy __proto__ key', () => {
+ const wire = Buffer.from('81a95f5f70726f746f5f5f81a7697341646d696ec3', 'hex');
+ const decoded = msgpack.unpack(wire, { lazy: true });
+ assert.equal(Object.getPrototypeOf(decoded), Object.prototype);
+ assert.equal({}.isAdmin, undefined);
+ assert.deepEqual(Object.keys(decoded), ['__proto__']);
+ const proto = decoded.__proto__;
+ assert.equal(proto.isAdmin, true);
+ assert.equal({}.isAdmin, undefined);
+ });
+
+ it('keeps constructor as an own accessor, not Function.prototype', () => {
+ const wire = Buffer.concat([
+ Buffer.from([0x81]),
+ msgpack.pack('constructor'),
+ msgpack.pack(1)
+ ]);
+ const decoded = msgpack.unpack(wire, { lazy: true });
+ assert.equal(decoded.constructor, 1);
+ assert.equal(Object.getPrototypeOf(decoded), Object.prototype);
+ });
+
+ it('still unpacks eagerly with one argument', () => {
+ const src = { a: 1, b: 2, c: [1, 2, 3] };
+ const o = msgpack.unpack(msgpack.pack(src));
+ assert.deepEqual(o, src);
+ assert.equal(typeof o.toJSON, 'undefined');
+ });
+
+ it('materializes one nested index without reading sibling keys', () => {
+ const o = msgpack.unpack(msgpack.pack({ a: 1, b: 2, c: [1, 2, 3] }), { lazy: true });
+ assert.equal(o.c[1], 2);
+ });
+
+ it('JSON.stringify of lazy unpack matches eager unpack', () => {
+ const src = { a: 1, b: 2, c: [1, 2, 3] };
+ const packed = msgpack.pack(src);
+ assert.equal(
+ JSON.stringify(msgpack.unpack(packed, { lazy: true })),
+ JSON.stringify(msgpack.unpack(packed))
+ );
+ });
+
+ it('rejects toJSON when this is not a lazy object', () => {
+ const o = msgpack.unpack(msgpack.pack({ a: 1 }), { lazy: true });
+ assert.throws(() => o.toJSON.call({}), /invalid lazy object/);
+ assert.throws(() => o.toJSON.call(null), /invalid lazy object/);
+ });
+
+ it('still throws on an oversized array header in lazy mode', () => {
+ const buf = Buffer.from([0xdd, 0xff, 0x00, 0x00, 0x00]);
+ assert.throws(() => msgpack.unpack(buf, { lazy: true }), /limit exceeded/);
+ });
+
+ it('still throws on an oversized map header in lazy mode', () => {
+ const buf = Buffer.from([0xdf, 0xff, 0x00, 0x00, 0x00]);
+ assert.throws(() => msgpack.unpack(buf, { lazy: true }), /limit exceeded/);
+ });
+
+ it('indexed lookup on a lazy map falls through to named keys', () => {
+ const o = msgpack.unpack(msgpack.pack({ 1: 'a', b: 2 }), { lazy: true });
+ assert.equal(o[1], 'a');
+ assert.equal(o[99], undefined);
+ assert.equal(99 in o, false);
+ assert.equal(1 in o, true);
+ });
+
+ it('still reads str and bin after the caller Buffer is transferred', () => {
+ const packed = msgpack.pack({
+ s: 'hello-lazy-uaf-marker-ABCDEFGH',
+ t: 'second-string-XXXXYYYY',
+ n: 7,
+ b: Buffer.from('bin-payload-1234')
+ });
+ const buf = Buffer.allocUnsafeSlow(packed.length);
+ packed.copy(buf);
+ const o = msgpack.unpack(buf, { lazy: true });
+ structuredClone(buf.buffer, { transfer: [buf.buffer] });
+ assert.equal(buf.buffer.byteLength, 0);
+ assert.equal(o.n, 7);
+ assert.equal(o.s, 'hello-lazy-uaf-marker-ABCDEFGH');
+ assert.equal(o.t, 'second-string-XXXXYYYY');
+ assert.equal(Buffer.isBuffer(o.b), true);
+ assert.equal(o.b.toString(), 'bin-payload-1234');
+ });
+});