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 type | classic | 0.14.0 |
|---|---|---|
v | [parsedSignatureTree, [value]] | the value |
a{sv}, a{ss} | array of [key, value] pairs | a plain object |
x, t | lossy number, or long.js objects | bigint |
ay | Buffer | Buffer, 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:
-
Convert reads to
variantValue()andtoPlain()on your current version. Nothing changes behaviourally; every converted call site is one that the flag day cannot break. -
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.0It 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. -
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.
-
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.
xandtcome back as roundednumbers 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 lintandtscthen 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.md —
variantValue,variantSignature,toPlain,Variant - RELEASE_PLAN.md — why these changes are grouped into one major, and what comes after