Skip to main content

BIG_FUTURE_PLANS.md

What dbus-native would look like if it were designed today, with backwards compatibility off the table.

Revised 2026-07-30, at v0.12.0. The first draft was written 2026-07-28, before 0.6 through 0.12 shipped. Roughly half of it is now in the package, so a straight re-read would be misleading. This revision does three things: records what landed, records where building it proved the design wrong, and states what perfect DX looks like from here.

The corrections in §2 are the point of this document. They are not second thoughts — each one is something that only became visible by shipping the thing next to it.

Everything marked ✅ has code and tests. Everything else is a proposal. Feature availability was re-checked on Node 26 (§4), not assumed.


0. Where the sketch was right

The original §0 listed what was wrong with the library. Most of it is fixed.

the complaintthennow
errors are not Errorserr is an array of stringsDBusError with dbusName, body, a real stack (0.7)
everything is a callback#9 open since 2013✅ promises everywhere, callbacks still supported
no timeouts, no cancellationbus.cookies leaked forevertimeout, AbortSignal, entries removed on settle
no types#276✅ hand-written index.d.ts + dbus-native types codegen
reading a value walks a parse treedict.find(…)[1][1][0]✅ the value itself, by default; variantValue()/toPlain()
64-bit is lossynumber, or a long.js objectbigint, by default
nothing is inspectable"I sent something and it hung"diagnostics_channel, dbus-dissect, dbus-native call
tests need a system daemonbrew install dbuscreateBroker() — in-process bus, 161 tests run on it
the server side is a stubhardcoded 2014 GUID✅ real SASL, three mechanisms, match rules, routing
properties are all readwrite#89access declared, enforced, and in the introspection XML
a bad name produced a dead message#309✅ validated on export and send

What is left from the original sketch: await using (§1), proxies (§2), the value-shape flag day (§3), async-iterable signals (§5), defineInterface (§7), and ESM (§9). That is the breaking window, and it is the subject of the rest of this document. Everything in it has now shipped, except ESM — which §4.1 argues should not be done at all.

The single most important thing the sketch got right, and it is worth restating because it constrains everything below: the wire layer is good and must not be rewritten. Marshalling, parsing and framing were measured and hardened across 0.5–0.12. All of the work here is above them.


1. The shape of a program

Before the corrections, here is the target, so the corrections have something to attach to.

import { sessionBus, Variant } from 'dbus-native';

await using bus = await sessionBus();

const notifications = await bus.proxy(
'org.freedesktop.Notifications',
'/org/freedesktop/Notifications'
);

const id = await notifications.Notify(
'example',
0,
'',
'summary',
'body',
['default', 'Open'],
{ urgency: new Variant('y', 1) },
5_000
);

using sub = notifications.on('NotificationClosed', ([closed, reason]) => {
if (closed === id) console.log('closed:', reason);
});

Every resource in that snippet is released by the language rather than by discipline. That is the whole thesis.


2. What building it proved wrong

2.1 The parse tree was never the right "typed" shape — Variant is ✅

The original §3 said a variant reads as "the value, or Variant when asked". Shipping plainValues showed there is no way to ask, and that this is not a small omission: two real consumers hit it inside a week.

  • dbus-native call prints variant u 501. That u exists only in the parse tree, so the CLI had to pin plainValues: false to keep printing types.
  • A service receiving a{sv} cannot discover what types its caller sent. The integration test for it has a branch that asserts undefined and explains why, which is the shape of a gap, not a test.

The fix is not a third read mode. It is noticing that the parse tree was always the wrong carrier — it leaked the parser's internals, it is unreadable in a debugger, and it cannot be sent back. Variant already exists, already carries signature and value, already has a custom inspect, and the marshaller already accepts it on the write path.

So 2.0 has two read shapes, not three:

// default: the value
const level = await dev.props.Percentage; // 87

// opt in per connection, or per call where it matters
const bus = await sessionBus({ variants: 'wrap' });
const v = await dev.props.Percentage; // Variant('d', 87)
v.signature; // 'd'
v.value; // 87
variantValue(v); // 87 -- the accessor already handles it

variants: 'wrap' is strictly better than the classic tree at the one job the tree had, and it round-trips: a value read in wrap mode can be sent straight back out. The tree survives only inside withClassicTypes, which is where a legacy shape belongs.

This is the highest-value correction in this document. It turns a known gap into a better API than the one being replaced.

Shipped. variants: 'tree' | 'plain' | 'wrap', defaulting to whatever plainValues implied so it is purely additive. The whole integration suite runs under it (npm run test:integration:wrap) and found no place in the library that indexes into a variant — only the tests written to assert a specific shape, plus one real bug: withClassicTypes had to pin variants: 'tree' as well, or a caller who opted into 'wrap' kept their Variants through it.

dbus-native call is the proof it earns its keep. It used to pin plainValues: false purely to keep printing variant u 501; it now runs on the full 2.0 shapes with variants: 'wrap' and prints byte-identical output.

2.2 ay stays a Buffer — the two documents disagreed

The original §3 table said ayUint8Array on web-standards grounds. RELEASE_PLAN.md §2.0 later argued the opposite and is right: Buffer is a Uint8Array subclass, so every consumer of the latter already accepts the former, while buf.toString('utf8') — used constantly — does not exist on a plain Uint8Array. Breaking it costs real code and buys nothing in a Node-only library.

The table in this document is now corrected. ay is a Buffer, unchanged.

2.3 A member-name proxy hangs on await unless then is guarded

The original §2 described a get trap that resolves any member name. Awaiting such a proxy — directly, or by returning one from an async function — makes await look up .then, find a function, call it with (resolve, reject), and wait forever. Demonstrated, not theorised:

const naive = new Proxy(
{},
{
get:
(t, k) =>
(...a) =>
`called ${String(k)}`
}
);
await naive; // hangs. Permanently.

Any proxy design must return undefined for then, and should also pass through Symbol.toStringTag, util.inspect.custom, constructor and Symbol.iterator rather than manufacturing methods for them. This is a one-line fix and a multi-hour debugging session for whoever hits it, which is exactly the kind of thing a design document should carry.

Two consequences for the design:

  • bus.proxy() returns a promise of a proxy, and the proxy itself is never a thenable. Introspection happens in proxy(), not lazily in the trap.
  • The $-prefixed members (props.$all, props.$set) are worth keeping, and for a better reason than the original gave: a D-Bus member name matches [A-Za-z_][A-Za-z0-9_]*, so $ is a guaranteed-collision-free namespace rather than merely an unlikely one.

2.4 Signals: the callback form is primary, not the iterable ✅

The original §5 led with for await. That emphasis is wrong, for two reasons that only became clear with the async-iterator-helper check.

Async iterator helpers are still not in Node (re-verified on 26 — §4). So .map(), .filter(), .take() do not exist on these streams, which removes most of what made the iterable form attractive. Composing by hand with an async generator works, but that is a worse API than a callback.

More fundamentally, an async iterator is a queue and a signal is a broadcast. If the consumer is slower than the bus, something has to give, and the original buried that decision in a queueing option. Perfect DX does not make silent unbounded buffering reachable at all:

// primary: a callback, with a subscription the language releases
using sub = nm.on('StateChanged', ([state]) => {
/* ... */
});

// convenience: iteration, when you genuinely want to consume in sequence
for await (const [state] of nm.signal('StateChanged', { queue: 64 })) {
if (state === CONNECTED) break; // removes the match rule
}

queue is required to be a bound or the literal 'latest'. There is no unbounded option, because a long-lived daemon with an unbounded signal queue is a memory leak with a countdown, and this library's users skew toward long-lived daemons.

Shipped as proxy.$watch() and proxy.$signal(). Two details the sketch did not settle: overflow drops the oldest, because a consumer catching up wants current state rather than what it already missed, and the count is on iterator.dropped so it is not silent. $watch earns its place separately from $on by resolving once the match rule is actually in place — $on cannot report that, so a signal emitted immediately after subscribing was a coin flip.

The queue policy is lib/signal-stream.js, which is pure logic and unit-tested without a bus; only "the rule really goes on and comes off" needs a daemon.

2.5 The value-shape gate is the precondition ✅

Not a correction so much as a change in what was possible. The whole suite runs under each of the three value shapes, against both dbus-daemon and the in-process broker — six runs. Before it existed the flag day was unverifiable; the measurement that motivated it found 9 failures, all of them in tests asserting old shapes rather than in the library.

The flip has since happened, and the gate was worth more than that suggests. Two things it caught that the 2.0 run alone would not have:

  • The wrap run found a real routing bug. lib/broker.js read variants flattened and re-marshalled them, so Variant('u', 9) was delivered to the next hop as i — a type its sender never wrote. The lesson generalises: a router must opt out of every convenience shape, because each one discards something (the signature, duplicate dict keys, the low bits of a 64-bit integer) that the next hop was entitled to.
  • Keeping classic as a run of its own is what makes withClassicTypes a supported escape hatch rather than a claim. An escape hatch nothing exercises stops working quietly.

The unit suite has no such gate, and that showed: several files read a shape through the default rather than naming it, so the flip would have turned them into assertions that pass trivially. Naming both shapes explicitly is the fix, and is now the rule in AGENTS.md.


3. What the sketch missed entirely

3.1 ObjectManager — the biggest real-world gap

Not mentioned anywhere in the original document, and it is how you enumerate anything on a modern bus. BlueZ, NetworkManager, systemd and UDisks all expose their object trees through org.freedesktop.DBus.ObjectManager. Today the library has a // TODO: emit ObjectManager's InterfaceAdded in lib/bus.js and no client-side support at all, so "list the Bluetooth devices" — the single most common thing anyone wants — means hand-decoding a{oa{sa{sv}}}.

Both halves are needed.

Consuming:

const bluez = await bus.objects('org.bluez', '/');

const devices = bluez.filter('org.bluez.Device1'); // { path: { iface: props } }

using sub = bluez.on('added', (path, interfaces) => {
/* ... */
});
using gone = bluez.on('removed', (path, interfaces) => {
/* ... */
});

bus.objects() calls GetManagedObjects once, subscribes to InterfacesAdded/InterfacesRemoved, and keeps a live view. That is the thing people write by hand today, badly, and it composes with §2.1: the properties arrive as plain objects.

Exporting: a service that exports objects under a path should be able to answer GetManagedObjects and emit the signals without writing any of it:

await using tree = await bus.exportTree('/com/example');
await tree.add('/com/example/Thing1', thing); // InterfacesAdded emitted

This closes a whole category of tracker questions and is, in my judgement, worth more per line than proxies.

3.2 Reconnection ✅

A daemon that loses the bus currently has no story at all. For an audience that skews toward Raspberry Pi and Homebridge — where ~41k weekly downloads sit on a fork of this package — that is a real gap. It does not have to be automatic, but it has to be possible and documented:

const bus = await sessionBus({
reconnect: { retries: Infinity, backoff: 'exponential' }
});
bus.on('reconnected', () => {
/* names re-requested, match rules re-added */
});

The hard part is not the socket, it is that a reconnect invalidates the unique name, every match rule and every owned name. Whatever ships must re-establish those or say loudly that it does not.

Shipped, opt-in, and it does re-establish all three -- before reconnected fires, so a service is reachable by the time anyone hears about it. What it does not do is retry calls that were in flight: they were already failed when the socket went, and a method call is not idempotent. Re-issuing is the caller's decision, which is what the event is for.

Building it turned up two bugs that had nothing to do with reconnection and everything to do with nothing ever having reconnected before: lib/broker.js removed the parent directory of its socket on close whether or not it had created it, and bus.names held the unique name alongside the well-known ones -- harmless until something tried to re-request them.

3.3 The file-descriptor transport seam — the one breaking thing that must land now

ROADMAP.md §2.8 scoped UNIX_FD on 2026-07-29 and concluded that the feature is not buildable today (no Node ancillary-data API; the one viable addon needs a compiler on every install) but is additive when it becomes possible.

The part that matters here: carrying descriptors means a message is { bytes, fds } rather than a Buffer, in both directions. That touches connection.message(), the cork/uncork write batching, unmarshalMessages(), and opts.stream — the seam a caller supplies their own transport through.

So the feature is a minor, but the seam is a major. If the breaking window closes without it, UNIX_FD costs another major later. Defining stream.writeWithFds? / stream.on('fds') as an optional capability now, with nothing behind it, is cheap insurance and belongs in this release train.

Shipped, and it turned out to be more than a seam. Once the message is { bytes, fds } and h is the uint32 index the spec always said it was, the only missing piece is the stream — so the whole protocol is implemented and tested against a mock transport, and UNIX_FD works today for anyone who supplies one. The package still depends on nothing. That is a better outcome than the placeholder this section asked for, and it came from noticing that "not buildable" was about the transport, never about the protocol.


4. The platform moved — re-checked on Node 26

Two of these change decisions the original made.

featurestatus on Node 26consequence
Symbol.asyncDispose✅ from Node 20§1 works across the whole supported range
await using keyword✅ from Node 24consumer's choice; unusable in our tests
AsyncDisposableStack✅ from Node 24§1, feature-detected
Promise.withResolvers()✅ nativesimplifies the cookie table
AbortSignal.timeout(), Error.cause✅ nativeshipped already
require() of an ESM moduleunflagged (22.12+)§4.1 — and ESM-only turns out to be moot
TypeScript type strippingunflaggedchanges the codegen story, not decorators
Sync iterator helpers✅ native
Async iterator helpers❌ still absent§2.4 — callbacks lead
Decorators❌ still a SyntaxError§7 stays defineInterface

4.1 ESM-only: measured, and the answer is no

Correction, and the last one this document needs. Everything below §4 was written from the cost side: how much would ESM-only hurt, now that require(esm) exists? That was the wrong question. The right one is what it buys, and the answer turns out to be nothing at all.

The current CJS package was installed from its own tarball into a clean project and imported from ESM. Every consumption shape works today:

import dbus from 'dbus-native'; // default
import { sessionBus, Variant, toPlain } from 'dbus-native'; // named
import { withClassicTypes } from 'dbus-native/compat'; // subpath
import marshall from 'dbus-native/lib/marshall.js'; // deep subpath
new Variant('s', 'x') instanceof dbus.Variant; // true, across the boundary

All 23 runtime exports are visible as ESM named imports — checked by diffing Object.keys(require(…)) against import * as ns, not by spot-check, because cjs-module-lexer is a static analysis and the failure mode is a silently missing named export. index.js assigns several exports after the initial object literal and the lexer finds those too.

So the interop matrix has no hole in it either way:

package isCJS consumerESM consumer
CJS (today)native✅ works — verified above
ESM-onlyneeds require(esm), Node ≥ 22.12native

The asymmetry is the whole argument. Staying CJS costs an ESM consumer nothing. Going ESM-only costs every CJS consumer on Node < 22.12, plus anyone whose bundler or toolchain does not implement require(esm) — and the floor in engines does not help, because that constrains what we run on while a consumer on Node 20 can require() this package perfectly well today.

For an audience that is 61% Homebridge on Raspberry Pi, that is a real cost against a benefit RELEASE_PLAN already described as "the only break in the plan with no functional payoff". Recommendation: do not do it. Not "defer it" — the reason it looked worth doing was an interop gap that measurement says is not there.

What survives from the original reasoning is the narrow bit: a dual package really would ship two copies of Variant and break instanceof across them. That remains a good reason never to publish one. It is not a reason to abandon CJS.

If this is ever revisited, the one rule that must hold is no top-level await in any entry pointERR_REQUIRE_ASYNC_MODULE, verified — since that is what makes require(esm) work at all.


require(esm) is the significant one. The original argued for ESM-only on the grounds that a dual package ships two copies of Variant and breaks instanceof. That argument still holds. But the cost of ESM-only has dropped sharply: a CJS consumer can now require() an ESM dbus-native directly — provided the entry point has no top-level await, which otherwise fails with ERR_REQUIRE_ASYNC_MODULE. Verified both ways.

That makes ESM-only tractable much earlier than the original assumed, and turns "no top-level await in any entry point" into a hard architectural rule worth writing down now.

Type stripping does not help decorators — verified: a decorator is still a SyntaxError under it, because decorators are a runtime feature and not an erasable type. The original's conclusion stands unchanged: defineInterface is the API, decorators are an optional export for people who already have a TypeScript pipeline.

Node 20 reached end of life on 2026-04-30.Raised to 22.12.0, and the patch number is the interesting part. The reasoning above said "22", but require(esm) — the thing that makes ESM-only tractable — is unflagged only from 22.12: 22.0 fails it with ERR_REQUIRE_ESM, which I measured rather than assumed after writing §4 on the strength of "22.12+". A floor of >=22.0 would have quietly given up the option this document argues for.

24 would buy await using in our own source. It is not worth it: the embedded audience upgrades slowly, and the syntax is a convenience for us rather than a capability. Symbol.asyncDispose works from 20 and so works everywhere we support.


5. Revised type mapping

Superseding the original §3 table, with §2.1 and §2.2 folded in.

D-Bus1.x2.0
s o gstringstring
y n q i unumbernumber
x tlossy number, or a long.js objectbigint
dnumbernumber
bbooleanboolean
ayBufferBuffer — corrected, see §2.2
a{sv} a{ss}array of [key, [tree, [value]]]plain object
a{iv} etc.array of pairsarray of pairs — a key must be a string
asarrayarray
(...)arrayarray (tuple)
v[tree, [value]]the value, or Variant under variants: 'wrap'
hthrowsthrows, with the seam defined — §3.3

6. What is actually breaking, and what is not

The useful cut, now that the gate exists to verify it.

Breaking, needs the major:

  • the value shapes becoming the default (§5)done. Every failure it produced was a test asserting a shape rather than a defect — except one, which only the gate's wrap run caught: lib/broker.js read variants flattened and re-marshalled them, so a routed Variant('u', 9) was delivered as i. A router has to opt out of every convenience shape, not just the lossy 64-bit one it already knew about. The flip also made the plain shape unwritable at a bare v until write() learned to infer there, exactly as it already did inside a{sv}.
  • the { bytes, fds } message seam (§3.3) — the feature is additive, the seam is not
  • ESM-only, with the no-top-level-await rule (§4)dropped, §4.1. Measured rather than argued: an ESM consumer can already import the CJS package completely, including every named export, subpaths and instanceof. ESM-only would buy nothing and would break require() for every consumer on Node < 22.12.
  • the Node floor (§4)
  • dropping long, ReturnLongjs, dbus2js, and lib/address-x11.js — which is published in lib/ and throws Cannot find module 'x11' on requiredone. Runtime dependencies are down to one, xml2js. long became a devDependency rather than disappearing: the marshaller still accepts a Long on input, recognised structurally by {low, high, unsigned} so it costs no import, and the tests need the real package to build one. ReturnLongjs throws rather than being ignored, because code that sets it expects a Long and would otherwise meet value.toNumber is not a function somewhere else entirely.
  • defineInterface replacing the positional descriptor arrays — shipped additively instead, which turned out to be the whole of it: it compiles to the classic descriptor, so exportInterface is unchanged and nothing downstream knows which spelling was used. Nothing had to break. The handler context (#230) came for free, because bus.js already passed the message after the arguments — what was missing was a shape around it rather than the data.

Additive, ships whenever it is ready:

  • await using / Symbol.asyncDispose ✅ shipped. bus.close(), bus.watch() and bus.ownName(), each disposable. Measured while building it: Symbol.asyncDispose is available from Node 20 but AsyncDisposableStack and the using keyword only from 24 — so the protocol works on the whole supported range and the keyword stays the consumer's choice, exactly as this document argued. The keyword cannot appear in our own tests at all: it is a syntax error on 20 and 22, which fails the file before a skip could run.
  • bus.proxy() — a new method beside getService()
  • bus.objects() and exportTree() (§3.1)
  • signal subscriptions and the iterable form (§2.4)
  • variants: 'wrap' (§2.1) — an option today, meaningful after the flip
  • reconnection (§3.2)
  • UNIX_FD itself, once a transport exists

That split is more favourable than the original assumed, and it suggests the order: land the additive ergonomics first, on 0.x, where they can be used and corrected by real consumers — then flip the defaults once. The flag day should be the last thing that happens, not the first, and by then the gate will have been green for months.


7. What I would still cut

Unchanged from the original, and re-confirmed:

  • The dbus: import specifier and the XML loader. Fashionable, fragile, breaks bundlers, costs a loader registration in every consumer. Native type stripping makes the codegen path better, which weakens the case further.
  • Decorators as the primary service API. Still not native.
  • Auto-inferring signatures from plain JS where no introspected signature is available. { a: 1 } could be a{sy}, a{si}, a{su}, a{sd} or a{sv}. Infer only where the signature is known; require Variant otherwise.

And one addition:

  • A third variant read mode. §2.1 replaces the parse tree rather than joining it. If variants: 'wrap' cannot do a job, that is a bug in Variant, not an argument for exposing the parser's internals again.

And one thing worth doing before any of it:

  • Answer #263. It asks whether this package should be deprecated in favour of dbus-next. Every proposal here is an argument that the answer is no, and none of them are worth building if the answer is yes. It is the cheapest issue on the list to close and the one that changes the most.

8. Risks

Mostly unchanged, with one downgraded and one added.

  • This is an API rewrite, not a refactor — but less of one than it was. Half the sketch shipped without breaking anyone, which is evidence the incremental path works better than expected. Downgraded.

  • Two live surfaces is how dbus-next and this package both ended up half-maintained. Whatever ships must be this package. Still the top risk.

  • 9.3k weekly downloads on the current API, plus 37k on the Homebridge fork that tracks it closely (npm, week ending 2026-07-28). The migration tooling now exists — lint rule, codemod, withClassicTypes, docs/migrating-to-2.0.md — which is the difference between a break and a fork.

  • The integration flake is mitigated, not explained. Root-caused and fixed. It was a real dispatch bug, and the instinct to chase it before building more on top was right: a connection answered method calls it was merely overhearing, because dispatch never checked msg.destination. A match rule with no type= makes the daemon deliver everything, and the eavesdropper's UnknownMethod reply carried the real sender's serial — settling somebody else's call, in another process, with an error. The suite's own match-rules.js corpus generates exactly that traffic.

    The suite runs in parallel again and is 3× faster. Worth noting what it says about the earlier evidence: "a connection with an empty exportedObjects receiving a call for a name it does not own" was reported as impossible under unicast routing, and it was — the message was never unicast to it.


Appendix: the tracker, checked

The original closed with "over half the open tracker", which was true when it was written and is now the wrong argument entirely — the tracker is down to 11 open issues. Checked 2026-07-30, because inheriting a stale list is how a design document starts justifying itself with issues that were fixed two releases ago. Ten of the fourteen this document originally listed as open are closed.

Of the 11, these are the ones the remaining work touches:

issuewhat it needs
#3✅ the value-shape flip (§5) — the oldest one, closed
#248bigint becoming the default (§5), closed
#141proxies (§1)
#104the gap against python-dbus ergonomics
#228a BlueZ user hunting for GattService — the shape of problem ObjectManager (§3.1) exists to remove
#263positioning: whether this package is the one to use

The rest are usage questions and connection-environment problems (#96, #158, #115, #85, #297), several of which are themselves DX arguments: nobody opens "callback never received" against a library whose traffic is inspectable by default.

So the case for this work is no longer "it closes half the tracker". It is that the tracker is nearly empty and the remaining complaints are about shape, not defects — which is exactly when a library gets to think about ergonomics.