Skip to main content

Migrating to 0.14.0

0.14.0 changed the shapes values arrive in. A variant becomes the value it holds, a string-keyed dict becomes a plain object, and 64-bit integers become bigint. That is most of the release, and most of this guide.

RELEASE_PLAN.md calls this release "2.0", and this file is still migrating-to-2.0.md because the 0.14.0 changelog and release notes link to it by that name. It shipped as 0.14.0 for the reason the 0.7 guide gives: under semver a 0.x minor is already the breaking bump, and 1.0.0 is a statement about stability worth making deliberately rather than as a side effect. Nothing about the content changed.

Three smaller breaks shipped alongside it: a default call timeout, the removal of ReturnLongjs and dbus2js, and a Node floor of 22.12. They are at the end, under The rest of the release.

It is the release people have been asking for since #3 in 2013, and it is the one that breaks the most code, because these shapes are what every invoke callback in your program reads.

Every change is still an option, now pointing the other way. Each of the new shapes was available before the flip and each of the old ones is available after it, per connection — so a program can move across in either direction, one connection at a time, rather than in a single step. If you have not upgraded yet, read Doing it before you upgrade first; if you have, and something broke, go to If you cannot migrate yet.


What changes

D-Bus typeclassic0.14.0
v[parsedSignatureTree, [value]]the value
a{sv}, a{ss}array of [key, value] pairsa plain object
x, tlossy number, or long.js objectsbigint
ayBufferBuffer, unchanged

ay stays a Buffer deliberately. Buffer is a Uint8Array subclass, so anything that accepts the latter already accepts the former, while buf.toString('utf8') does not exist on a plain Uint8Array. Changing it would cost real code and buy nothing in a Node-only library.


bigint is the sharp edge

Take this one first. The other two changes make code that reads a value shape fail loudly at the read. This one makes code fail somewhere else entirely, often much later, in a line you did not think was about D-Bus at all.

bigint is not a drop-in for number:

size + 1; // TypeError: Cannot mix BigInt and other types
size * 2; // TypeError, likewise
Math.round(size); // TypeError: Cannot convert a BigInt to a number
JSON.stringify({ size }); // TypeError: Do not know how to serialize a BigInt
size > 100; // fine, comparisons work across types
size === 100; // false! strict equality across types never holds
size == 100; // true, loose equality does
Number(size); // fine, with the precision loss you already had

The two that bite hardest are arithmetic and JSON.stringify. A metrics counter that adds a byte total, or an HTTP handler that serialises a device property, will throw at runtime on a value that used to work — and the stack trace points at your serialiser, not at the D-Bus call that produced it.

So: find every 64-bit value your program actually reads, and decide for each one whether it wants the range or the arithmetic.

// wants the range: a file size, a byte counter, a timestamp in microseconds
const size = await iface.Size(); // keep it a bigint

// wants the arithmetic: a value you know is small, feeding a percentage
const pct = Number(await iface.Level()) / 100;

Number() at the boundary is not a defeat. It is the same precision you had before 0.14.0, made visible in one place instead of applied silently to everything.

For JSON, convert at the edge:

JSON.stringify(payload, (key, value) =>
typeof value === 'bigint' ? value.toString() : value
);

Which of your values are 64-bit? Look for x and t in the interface's introspection XML:

dbus-native introspect --service org.freedesktop.UPower \
--path /org/freedesktop/UPower/devices/DisplayDevice --system

or generate typings and let the type checker find them, which is less work:

dbus-native types --system --service org.freedesktop.UPower \
--path /org/freedesktop/UPower --out upower.d.ts

The default plain target emits the 0.14.0 shapes, so tsc reports every place a bigint meets a number before you have run anything. (Generate with --target classic first if you want to see the two side by side.)


Variants

// classic -- the shape behind more issues than anything else in this project
const value = result[1][0];

// 0.14.0
const value = result;

Nested in a dict, which is where it usually appears:

// classic
const udi = dict.find(([key]) => key === 'Udi')[1][1][0];

// 0.14.0
const { Udi } = dict;

Write it once, for both, using the accessor that shipped in 0.6:

const { variantValue } = require('dbus-native');

const value = variantValue(result); // identical before and after

variantValue() reads the classic wrapper and is the identity on a plain value, so a file converted to it is done — it needs no second pass at the flag day. This is what the accessors are for; see DBUS_DEP0002.

If you need the type, ask for a Variant

[parsedSignatureTree, [value]] carried the variant's type. The plain shape does not:

variantSignature(result); // 's' before 0.14.0, undefined from it

Almost nobody reads it. If you do — a tool that prints what came back, or a service that dispatches on the type of an a{sv} value — ask for it:

const bus = dbus.sessionBus({ variants: 'wrap' });

const v = await iface.$readProp('Volume');
v.signature; // 'd'
v.value; // 0.5
variantValue(v); // 0.5, same as every other shape

A Variant is what the tree should have been: it prints as Variant('d', 0.5) rather than a wall of parse-tree objects, and the marshaller accepts it, so a value read this way can be sent straight back out. Do not migrate to variants: 'tree' to keep the signature — it works, but it is the shape 0.14.0 exists to remove, and withClassicTypes is the supported way to stay on it.

Writing variants is unaffected either way: new Variant('u', 9) and the ['u', 9] pair both still work.


Dicts

// classic
for (const [key, value] of dict) {
console.log(key, variantValue(value));
}
const name = dict.find(([key]) => key === 'Name')[1][1][0];

// 0.14.0
for (const [key, value] of Object.entries(dict)) {
console.log(key, value);
}
const { Name } = dict;

Write it once, for both, with toPlain(), which converts a whole reply recursively — pairs to objects, variants unwrapped — and is the identity on a 0.14.0 value:

const { toPlain } = require('dbus-native');

const props = toPlain(await getAll()); // { Name: 'eth0', Mtu: 1500 }

Only dicts with string-like keys become objects — s, o and g. a{is} and friends stay arrays of pairs in both versions, because an integer key is not a JavaScript property name and a 64-bit one would lose precision on the way back. (toPlain() converts them anyway, stringifying the keys, if that is what you want — so a file using it sees objects where the parser hands out pairs.) And a dict is told from a struct array by the parser rather than by shape, so a(ss) is never mistaken for a{ss}.

Writing a dict has accepted a plain object since 0.11.0, so code that sends { Name: 'eth0' } needs no change. See DBUS_DEP0003.


Doing it before you upgrade

None of the above has to wait for the upgrade. The new shapes have been options since 0.11, per connection:

const bus = dbus.sessionBus({ plainValues: true, returnBigInt: true });

That is the same code path 0.14.0 turns on by default — not a simulation of it. So the migration that actually works is:

  1. Convert reads to variantValue() and toPlain() on your current version. Nothing changes behaviourally; every converted call site is one that the flag day cannot break.

  2. Find the rest with the linter. Reading a variant is an index chain, and nothing in the source says what is at the end of it, so there is no complete codemod for this release — anyone promising one has not thought about it. The linter narrows it to a reviewed list instead:

    npx dbus-native lint src/
    src/net.js:42 DBUS_DEP0002 variant index chain `[1][1][0]`
    -> variantValue(), or a plain property read after 0.14.0

    It exits non-zero when there are findings, so it can gate CI while you work through them. Dict findings are marked (possible): for (const [k, v] of xs) is also ordinary JavaScript, and a linter that cries wolf gets switched off.

  3. Turn the options on, one connection at a time. Because they are per connection, a large program can move a subsystem at a time against its real bus rather than all at once.

  4. Upgrade. If steps 1–3 are done, this release is a version bump.


If you cannot migrate yet

const { withClassicTypes } = require('dbus-native/compat');

const bus = withClassicTypes(dbus.sessionBus());

classic shapes on a 0.14.0 connection: variants wrapped, dicts as pairs, x/t as number. For code with result[1][1][0] in three hundred places and a reason to upgrade that is not this.

It shipped ahead of the flip, where it was a no-op, so the import could land before the flag day rather than during it — and it means an upgrade that breaks you is one line to unbreak while you work through the rest.

Four things to know:

  • It is scoped to the connection, not to the reference. It configures the bus you hand it and returns that same bus. There is one parser per socket, so an independent 0.14.0 view of the same connection is not something that can exist. Unrelated code with its own bus is unaffected.
  • Call it before your first call goes out. A reply that has already been parsed is already the new shape, and nothing can reach back and change it.
  • It restores the old lossiness too. x and t come back as rounded numbers again — that is what "classic" means, and it is the right answer if your code needs a Number, but do not reach for this merely to silence a BigInt error. You will be reintroducing the precision bug that #248 is about.
  • It is a holding position, not a destination. Delete the wrapper and fix what dbus-native lint and tsc then point at.

The rest of the release

Three smaller breaks, none of which need the tooling above.

Calls now have a deadline

A call waits 25 seconds for its reply and then rejects with a TimeoutError. Before 0.14.0 there was no default: a peer that never answered left the promise unsettled for the life of the process.

await bus.invoke(msg, { timeout: 60_000 }); // this one is genuinely slow
await bus.invoke(msg, { timeout: 0 }); // no deadline, as before
const bus = dbus.sessionBus({ timeout: 0 }); // ...or for the whole client

25 seconds is what libdbus, GDBus and sd-bus all use, so a call that hits this deadline would have hit theirs at the same point — this brings the package in line rather than inventing a policy.

This one breaks in an unusual direction: it makes previously-hanging calls start failing. That is the point. A promise that never settles cannot be caught, logged or retried, and any peer can cause one by crashing between receiving a message and answering it. But if you have a method that legitimately takes longer than 25 seconds, it needs timeout raised — and it will fail loudly rather than quietly, so you will know.

A message carrying NO_REPLY_EXPECTED gets no deadline and settles with undefined once written, since there is nothing to wait for. It used to hang here too, and leak one pending entry per call.

ReturnLongjs and dbus2js are gone

Both warned from 0.6. ReturnLongjs now throws rather than being ignored, because code that sets it expects a Long back and would otherwise meet value.toNumber is not a function somewhere far from the call:

const bus = dbus.sessionBus({ returnBigInt: false }); // a lossy number

dbus2js is replaced by dbus-native types; see DBUS_DEP0005 for the equivalent command. lib/address-x11.js is also gone — it required a package that was never a dependency, so it threw on require for everyone who found it.

Node 22.12 or newer

Node 20 reached end of life on 2026-04-30. 22.12 rather than 22.0 because that is where require() of an ESM module works unflagged, which keeps a future option open.


Reference

  • docs/deprecations.md — DBUS_DEP0001 (ReturnLongjs, removed), DBUS_DEP0002 (variants), DBUS_DEP0003 (dicts), with the runtime warnings and what silences each one
  • docs/api.mdvariantValue, variantSignature, toPlain, Variant
  • RELEASE_PLAN.md — why these changes are grouped into one major, and what comes after