Skip to main content

API reference

The complete public surface of dbus-native, as of 0.7.

This is the reference; the README is the introduction and RELEASE_PLAN.md is where the surface is going. Anything not listed here is internal, even if it is reachable — bus.cookies, lib/marshall, DBusInterface.prototype.$createMethod and friends are implementation, and change without a major.

Contents


Entry points

const dbus = require('dbus-native');
functionreturns
dbus.sessionBus(opts?)MessageBusconnects to $DBUS_SESSION_BUS_ADDRESS
dbus.systemBus()MessageBusconnects to $DBUS_SYSTEM_BUS_ADDRESS, or the standard system socket
dbus.createClient(opts?)MessageBusconnects to whatever opts describes
dbus.createConnection(opts?)DBusConnectionthe transport alone, with no MessageBus on top
dbus.createServer(handler?)DBusServeraccepts peer-to-peer connections — see below
dbus.createBroker(opts?)DBusBrokera message bus, not a replacement for dbus-daemon — see below

sessionBus and systemBus are createClient with a busAddress filled in; all three take the same options.

createServer

handler is called with a DBusConnection per accepted socket, and the returned object has a listen(...) delegating to net.Server#listen. The server side of the SASL handshake is real as of 0.12 — it offers EXTERNAL and DBUS_COOKIE_SHA1, generates a GUID per server, and takes authorize, anonymous, authMethods and authTimeout options. This is peer-to-peer: nothing routes between two connections and no names are assigned, so a client must connect with direct: true and address the peer directly.

createBroker

A message bus, in process:

const broker = dbus.createBroker();
broker.listen((err, address) => {
const bus = dbus.createClient({ busAddress: address });
});
member
broker.listen(where?, cb?){socket}, {port, host}, or a temporary unix socket
broker.address()the address to connect to, once listening
broker.names()the names on the bus, as ListNames would report them
broker.close(cb?)drop every client and stop listening
broker.guid, broker.idthe server GUID and the bus id

Events: listening, connection, hello, disconnect, error, clientError.

It implements org.freedesktop.DBusHello, RequestName/ReleaseName with the full flag and queue behaviour, ListNames, ListActivatableNames, NameHasOwner, GetNameOwner, ListQueuedOwners, AddMatch/RemoveMatch, GetId, GetConnectionUnixUser, GetConnectionUnixProcessID, StartServiceByName, UpdateActivationEnvironment, plus Peer, Introspectable and Properties — and it routes: unicast by destination, signals by match rule, NameOwnerChanged/NameAcquired/NameLost as names change hands.

What it is for is a bus the test suite can start without dbus-daemon installed; npm run test:integration:broker runs the whole integration suite against it. It is not a replacement for dbus-daemon: there is no security policy, so any client may own any name and call anyone; no service activation, so StartServiceByName always fails; no fd passing; and no eavesdropping, so dbus-monitor cannot see traffic addressed elsewhere. Do not put it on a socket other processes can reach.

Connection options

optiontypedefault
busAddressstring$DBUS_SESSION_BUS_ADDRESSencoded address; ;-separated alternatives are tried in order
socketstringunix socket path
portnumberTCP port
hoststringlocalhostTCP host
streamDuplexuse an already-connected stream
authMethodsstring[]['EXTERNAL', 'DBUS_COOKIE_SHA1', 'ANONYMOUS']tried in order
directbooleanfalseskip the opening Hello, for peer-to-peer connections
serverbooleanfalseact as the server side of the handshake
timeoutnumber25000ms every call waits for its reply; 0 disables — see below
ayBuffertrue | false | 'view'truehow ay comes back — see Values
maxMessageSizenumber128 MiBreject a message declaring more than this
returnBigIntbooleantrueread x/t as native bigint, exactly — see below
plainValuesbooleantrueread variants and dicts as plain values — see below
variants'tree'|'plain'|'wrap'follows plainValueshow a v comes back; 'wrap' keeps the signature — see below
reconnectboolean | objectfalsereconnect when the transport goes away — see below

Address forms understood by busAddress: unix:path=…, unix:abstract=…, unix:socket=…, tcp:host=…,port=…, unixexec:path=…,argv1=…, and launchd:env=….

unixexec: runs the bus as a child process and speaks to it over its stdio. path is the binary, argv1, argv2, … are its arguments, and the optional argv0 sets the program name the binary sees rather than being an argument itself. A missing argvX ends the list, so argv3 without argv2 is not read. Up to 0.14.0 these were read as arg1, arg2, … — a spelling no address generator produces, so a conformant address ran the binary with no arguments at all.

launchd:env=VAR is how macOS advertises its session bus. The address names an environment variable rather than a path; the socket is looked up with launchctl getenv VAR, falling back to this process's own environment when launchd has no answer for it. The lookup runs once, synchronously, during connection setup and costs about 4 ms.

On macOS, sessionBus() falls back to launchd:env=DBUS_LAUNCHD_SESSION_BUS_SOCKET when DBUS_SESSION_BUS_ADDRESS is unset, which is the normal state there — so it works without any setup beyond a running bus.


Callbacks and promises

Every asynchronous method takes an optional Node-style (err, result) callback. Omit the callback and you get a thenable instead — the callback path is completely unchanged.

bus.getId((err, id) => {}); // callback
const id = await bus.getId(); // promise

Resolution follows the number of values in the reply body, because most D-Bus methods return zero or one:

reply bodyresolves with
no valuesundefined
one valuethe value
two or morean array of values

The returned object is a thenable, not a Promise, and instanceof Promise is false. This is deliberate. Calling without a callback has always meant fire-and-forget — this repo's own examples do bus.invoke({ member: 'AddMatch', … }) and ignore the result — so the underlying promise is constructed lazily, on the first then/catch/ finally. Ignore the return value and nothing is constructed, so a dropped failure cannot become an unhandled rejection that terminates the process. await, Promise.all, .catch and .finally all work normally.

The call itself always runs immediately; only observing the outcome is lazy.

Rejections are always a DBusError, and carry the frames from where the call was made, stitched on after a --- d-bus call made at --- marker. Without that a rejected call would only show library internals, since the error is constructed in the socket read handler.


MessageBus

Calling methods

bus.invoke(msg[, options][, callback])
bus.invokeDbus(msg[, options][, callback])

invoke sends a method call and delivers the reply. invokeDbus is the same with path, destination and interface defaulted to the bus daemon's own org.freedesktop.DBus object.

msg fields:

fieldtype
destinationstringbus name to call
pathstringobject path
interfacestringinterface name
memberstringmethod name
signaturestringsignature of body; omit when there are no arguments
bodyunknown[]the arguments
typenumberdefaults to messageType.methodCall
flagsnumber

options:

optiontype
timeoutnumberms to wait; 0 disables. Defaults to the client's timeout
signalAbortSignalcancels the call; rejects with AbortError

A timeout or an abort removes the pending call, so neither leaks an entry nor delivers a late reply.

Every call has a deadline: 25 seconds by default, the same figure libdbus, GDBus and sd-bus use, so a call that hits it would have hit theirs at the same point. Before 0.14.0 there was none, and a peer that never answered left the promise unsettled for the life of the process.

await bus.invoke(msg, { timeout: 5000 }); // shorter
await bus.invoke(msg, { timeout: 0 }); // no deadline at all
await bus.invoke(msg, { signal: AbortSignal.timeout(5000) });

const bus = dbus.sessionBus({ timeout: 60_000 }); // per client

A method that legitimately takes longer than 25 seconds needs timeout raised for that call — the same thing you would do against any other D-Bus library.

A message carrying NO_REPLY_EXPECTED gets no deadline, because there is nothing to wait for: invoke settles with undefined once the message is written.

Bus daemon methods

Each takes an optional trailing callback and otherwise returns a thenable.

methodresolves with
bus.getId()string
bus.listNames()string[]
bus.listActivatableNames()string[]
bus.requestName(name, flags)number
bus.releaseName(name)number
bus.nameHasOwner(name)boolean
bus.getNameOwner(name)string
bus.startServiceByName(name, flags)number
bus.updateActivationEnvironment(env)
bus.getConnectionUnixUser(name)number
bus.getConnectionUnixProcessId(name)number
bus.addMatch(rule)
bus.removeMatch(rule)

Signals

Signals are delivered on bus.signals, an EventEmitter, keyed by a mangled path + interface + member string. Tell the daemon you want them first with a match rule:

await bus.addMatch(
"type='signal',path='/org/example',interface='org.example.Iface',member='Pinged'"
);
const key = bus.mangle('/org/example', 'org.example.Iface', 'Pinged');
bus.signals.on(key, (body, signature) => console.log(body));

bus.mangle(path, interface, member) or bus.mangle(msg) produces the key.

The proxy API wraps both steps — iface.on('Pinged', handler) adds the match rule for you.

File descriptors

h (UNIX_FD) is a uint32 index, not a descriptor — "the value is an index into the array of file descriptors that accompany the message", per the specification. The descriptors themselves travel as ancillary data (SCM_RIGHTS) beside the bytes, and ride on msg.fds:

bus.connection.message({
destination: 'org.example.Sink',
path: '/org/example/Sink',
interface: 'org.example.Sink',
member: 'Take',
signature: 'sh',
body: ['a name', 0], // ^ index into fds
fds: [fd]
});

and the same shape arrives:

bus.connection.on('message', msg => {
if (msg.fds) console.log(msg.fds[msg.body[1]]);
});

Node has no ancillary-data API, so nothing here provides a transport that can carry them — nodejs/node#53391 is closed as not planned, and every addon that does needs a compiler on every install, which is the property this package spent three releases acquiring. See ROADMAP.md §2.8 for what was measured.

So the capability is a seam on the stream. Supply your own transport as opts.stream implementing two things, and everything above it works:

on the stream
writeWithFds(bytes, fds)write, carrying descriptors; returns like write()
emit('fds', fds)descriptors received, in arrival order

test/utils/fd-transport.js is a working example, minus the kernel.

on the connection
connection.canPassFdswhether the transport declared the capability
connection.unixFdsAgreedwhether the peer agreed during the handshake

Both must be true before a descriptor gets anywhere. NEGOTIATE_UNIX_FD is only sent when the transport can carry one — claiming the capability and then failing on the first h would leave the peer with no way to know to use a different call.

Three details that matter if you write a transport:

  • Descriptors must arrive in the same order as their bytes. That is what lets each message take the number its UNIX_FDS header claims, which is how libdbus does it too. SCM_RIGHTS gives this for free.
  • An fd-carrying message is never batched. Ancillary data attaches to a write, not to a message, so a batched one would hand its descriptors to whichever message the kernel associated them with. Such a message flushes the cork and goes on its own.
  • Ownership is yours. Nothing here dups or closes anything; a received descriptor is a live fd in your process and closing it is your job.

Reconnecting

Off by default, and deliberately so: reconnecting changes what a connection means. The unique name is reassigned, so anyone holding the old one is talking to nobody, and every well-known name and match rule is gone until it is asked for again. That is a decision for the program.

const bus = dbus.sessionBus({
reconnect: { retries: Infinity, minDelay: 100, maxDelay: 30_000, factor: 2 }
});

bus.on('reconnected', ({ name, names }) => {
// new unique name, and `names` are back
});

reconnect: true takes the defaults above.

eventon
reconnecting {attempt, delay, cause}connectiona retry is scheduled
reconnectconnectionthe transport is back, nothing restored yet
reconnected {name, names}busnames and match rules are back
reconnectErrorbusreconnected, but restoring them failed
reconnectFailedconnectionretries exhausted, nothing more will happen

What comes back, before reconnected fires: the unique name (a fresh Hello), every well-known name in bus.names, and every match rule added through bus.addMatch — which includes everything bus.watch(), proxy.$watch() and bus.objects() install. Objects exported with exportInterface never went anywhere; they live on the bus, not on the socket.

What does not. A rule installed by calling invokeDbus({ member: 'AddMatch' }) directly is not recorded and will not return. And nothing in flight is retried — those calls were already failed with ConnectionClosedError when the socket went, and a method call is not idempotent, so replaying one could charge a card twice. Re-issuing is yours to decide, which is what reconnected is for.

Retries back off exponentially to maxDelay, because the usual reason a bus is unreachable is that it is restarting and hammering it does not help.

A pending retry does hold the event loop open, exactly as a live socket would. A daemon whose bus restarted should not exit during the gap — that is the case this exists for. bus.close() cancels the retry, so a program that wants to stop still can.

reconnect cannot be combined with opts.stream: a stream the caller supplied cannot be reopened, and pretending otherwise would redial the same dead socket forever. It throws at construction rather than at the first disconnect.

Scoped resources

A match rule is the thing people forget to remove. A process that adds them and never removes them has the bus deliver steadily more traffic to it, for signals nothing is still listening for. bus.watch() and bus.ownName() hand back something that releases itself:

const sub = await bus.watch("type='signal',interface='org.example.Iface'");
// …
await sub.remove();

const reg = await bus.ownName('com.example.Greeter');
if (!reg.isPrimaryOwner) console.log('queued behind', reg.result);
await reg.release();

Both implement Symbol.asyncDispose, as does the bus itself and the raw connection, so on Node 24+ the language can do it:

await using bus = dbus.sessionBus();
await using reg = await bus.ownName('com.example.Greeter');
await using sub = await bus.watch(
"type='signal',interface='org.example.Iface'"
);

or with AsyncDisposableStack, which is the shape of a whole service:

await using stack = new AsyncDisposableStack();
const bus = stack.use(dbus.sessionBus());
stack.use(await bus.ownName('com.example.Greeter'));
stack.use(await bus.watch("type='signal',interface='org.example.Iface'"));
// everything unwinds in reverse, whether the scope ends normally or throws

The protocol works on every supported Node; the syntax does not. Symbol.asyncDispose predates the 22.12 floor, so await x[Symbol.asyncDispose]() and the close()/remove()/release() methods work on the whole supported range. AsyncDisposableStack and the using keyword need Node 24 — that is the consumer's choice, not a floor this package imposes.

bus.close() resolves once the connection is really closed, by which point every in-flight call has been failed with ConnectionClosedError rather than left waiting. Messages already sent are flushed on the way out. It is idempotent and safe on a connection that has already gone.

It deliberately does not remove match rules or release names first: the bus drops both when the connection goes, so unwinding them by hand would be round trips whose only effect is a slower shutdown. Scope them individually when they need to end before the connection does — which is the case that matters.

Properties

property
bus.connectionthe underlying DBusConnection
bus.namethis client's unique name, once Hello has been answered
bus.signalsEventEmitter for incoming signals

Proxy API

Introspects a remote object and builds a JavaScript object from it.

bus.proxy()

The short way. Resolves each member against what the object actually declares, so you do not have to know which interface it lives on:

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

const id = await notifications.Notify('app', 0, '', 'hi', '', [], {}, 5000);
notifications.$on('NotificationClosed', (id, reason) => {});

await notifications.$props.$all();
await notifications.$props.$set('Volume', 0.5);
member
proxy.Member(...)call it, wherever it is declared
proxy.$props.Nameread a property — a promise
proxy.$props.$all()every readable property, in one GetAll
proxy.$props.$set(n, v)write one, or an object of several
proxy.$watch(sig, fn)subscribe; resolves when live, disposable
proxy.$signal(sig, opts)the same signal as a bounded async iterable
proxy.$on/$once/$offsignals, fire and forget
proxy.$as(interfaceName)the underlying interface, for anything else
proxy.$service, $pathwhat it stands for
proxy.$interfacesthe interfaces it dispatches across
proxy.$nodeschild object paths

Everything the proxy adds is $-prefixed, and that is a guarantee rather than a convention: a D-Bus member name matches [A-Za-z_][A-Za-z0-9_]*, so $ is an impossible first character and nothing can collide.

An ambiguous member throws, naming both interfaces. Two interfaces on one object may declare the same member, and picking one silently would send the call to whichever introspected first. Narrow it:

const player = await bus.proxy(
'org.mpris.MediaPlayer2.vlc',
'/org/mpris/MediaPlayer2',
{
interface: 'org.mpris.MediaPlayer2.Player'
}
);

proxy.then is always undefined, even if the service really does declare a member called then — reach that one through $as(). A proxy that answered then with a function would make await proxy call it and wait forever for a resolve that never comes. Since bus.proxy() is itself async, that hang would happen at construction rather than at first use.

Assignment is refused, for properties and members alike. obj.x = v evaluates to v rather than to a promise, so a failed write would be silently lost; $props.$set() can be awaited.

Signals from a proxy

The callback form is the primary one. $watch resolves once the match rule is really in place — which $on cannot report, so a signal emitted immediately after subscribing used to be a coin flip — and hands back something that unsubscribes:

const sub = await nm.$watch('StateChanged', state => {});
await sub.remove(); // or Symbol.asyncDispose, so a scope can release it

Iteration is the convenience, for consuming in sequence:

for await (const [state] of nm.$signal('StateChanged')) {
if (state === CONNECTED) break; // removes the match rule
}

Leaving the loop — break, return, a throw, or an AbortSignal — removes the subscription, which is the point of the shape.

It is deliberately the secondary API. Async iterator helpers are not in Node yet (checked on 26), so .map(), .filter() and .take() do not exist on these streams, and composing by hand with an async generator is a worse API than a callback.

const stream = nm.$signal('StateChanged', {
queue: 'latest', // or a positive integer; 64 by default
signal: AbortSignal.timeout(30_000)
});

There is no unbounded option. An async iterator is a queue and a signal is a broadcast, so a slow consumer has to lose something — and an unbounded signal queue in a long-lived process is a memory leak with a countdown. When the bound is reached the oldest is dropped, because a consumer catching up wants current state rather than what it already missed, and iterator.dropped says how many went so it is not silent.

console.log(proxy) prints what it stands for and what it can do, rather than walking the connection:

DBusProxy com.example.Proxy /com/example/Proxy
methods: Add, Echo, Get, GetAll, GetMachineId, Introspect, Ping, Set
properties: Greeting, Level
signals: Pinged, PropertiesChanged

The explicit form

Still there, and what proxy() is built on. Use it when you want one named interface and nothing else:

const iface = await bus
.getService('org.freedesktop.Notifications')
.getInterface(
'/org/freedesktop/Notifications',
'org.freedesktop.Notifications'
);

await iface.Notify('app', 0, '', 'summary', 'body', [], {}, 5000);
callreturns
bus.getService(name)DBusService (synchronous)
service.getObject(objectPath[, cb])DBusObject
service.getInterface(objectPath, ifaceName[, cb])DBusInterface
bus.getObject(serviceName, objectPath[, cb])DBusObject
bus.getInterface(serviceName, objectPath, ifaceName[, cb])DBusInterface

Note the argument order on the bus.* forms: service name first, then object path.

DBusObject

member
obj.namethe object path — always the one you asked for
obj.servicethe owning DBusService
obj.proxyevery introspected interface, by name
obj.nodesthe names of the child objects, relative to this path
obj.as(ifaceName)one interface — throws UnknownInterfaceError if absent

A path that groups other objects and implements nothing itself — a container, like /org/freedesktop/UPower/devices — comes back with an empty proxy and its children in nodes. Walk down from there:

const container = await service.getObject('/com/example/Devices');
for (const child of container.nodes) {
const device = await service.getObject(`/com/example/Devices/${child}`);
}

Before 0.12 such a path was silently replaced by its first child: you got a proxy for /com/example/Devices/<first> while believing you had the container, calls went to an object you never named, and the other children were dropped. Asking a container for an interface now throws UnknownInterfaceError, and the message names the children.

DBusInterface

Methods are functions taking the method's arguments, plus an optional callback:

const result = await iface.Echo('hi');
iface.Echo('hi', (err, result) => {});

Properties are read by calling the accessor, and written by assignment:

const greeting = await iface.Greeting(); // read
iface.Greeting = 'hello'; // write, fire-and-forget
await iface.$writeProp('Greeting', 'hello'); // write, awaitable
await iface.$readProp('Greeting'); // read, explicit

Assignment cannot be awaited, so a failed write has nowhere to report. Use $writeProp when you need to know it succeeded.

Signals use the EventEmitter methods, and add the match rule for you:

iface.on('ActionInvoked', (...args) => {});
iface.once('ActionInvoked', handler); // fires at most once, then unsubscribes
iface.off('ActionInvoked', handler); // removes the match rule when the last listener goes
iface.removeAllListeners('ActionInvoked'); // or with no argument, every signal
iface.listenerCount('ActionInvoked');

on/once/off return the interface, so they chain.

Installing the match rule is a round trip to the daemon, and on cannot report when it finishes or that it failed. $subscribe/$unsubscribe do both:

// resolved => the daemon is routing the signal to us
await iface.$subscribe('ActionInvoked', handler);
await iface.$unsubscribe('ActionInvoked', handler);

Prefer them when you are about to trigger whatever emits the signal, or when AddMatch failing is something you want to catch — from on it surfaces as a connection handlerError instead.

$signals lists what the interface declared, in the same [signature, ...argumentNames] shape a service is exported with:

iface.$signals; // { ActionInvoked: ['su', 'id', 'action'] }

Exporting a service

defineInterface()

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

let volume = 0.5;

const greeter = defineInterface({
name: 'com.example.Greeter',
methods: {
Hello: {
in: { name: 's' },
out: { greeting: 's' },
handler: ({ name }, { sender }) => `Hello ${name}, from ${sender}`
}
},
properties: {
Volume: {
type: 'd',
get: () => volume,
set: v => {
volume = v;
}
},
Version: { type: 's', access: 'read', get: () => '1.2.3' }
},
signals: { Greeted: { args: { who: 's' } } }
});

await bus.requestName('com.example.Greeter', 0);
await using reg = await bus.export('/com/example/Greeter', greeter);

greeter.emit.Greeted('world');

Four things this has that the positional form does not:

  • Arguments have names in the source, not just in the introspection XML, and a handler receives them as an object. in: { name: 's', count: 'u' } says what 'su' meant.
  • A handler is told who called it{ sender, path, interface, member, message }. That was reachable before only because the raw message happens to be passed after the arguments, which is #230.
  • A property can be computed. get/set are functions, so a value can be derived rather than stored, and a set still gets PropertiesChanged emitted for it automatically.
  • Mistakes are caught where they were written. An unknown key, a set on a read-only property, an invalid member name — all rejected by defineInterface() rather than at export or on the first call.

Returning values: give the value directly when one out is declared, and an object keyed by the out names when there are several.

Split: {
in: { text: 's' },
out: { head: 's', tail: 's' },
handler: ({ text }) => ({ head: text[0], tail: text.slice(1) })
}

That asymmetry is deliberate and matches how a reply is read: invoke hands back the value for a one-value reply and an array for several.

bus.export(path, definition) resolves to a registration with remove() and Symbol.asyncDispose, so an exported object can be scoped like any other resource. greeter.emit.Greeted(...) throws if the interface has not been exported yet, rather than emitting into nothing.

The positional form

Still supported, still what exportInterface takes, and what defineInterface compiles to:

const ifaceDesc = {
name: 'com.example.Iface',
methods: {
// name: [inputSignature, outputSignature, inputNames, outputNames]
Echo: ['s', 's', ['input'], ['output']]
},
signals: {
// name: [signature, ...argumentNames]
Pinged: ['s', 'payload']
},
properties: {
// a signature alone means readwrite
Greeting: 's',
// or declare the access explicitly
Locked: { type: 'b', access: 'read' },
Secret: { type: 's', access: 'write' }
}
};

const impl = Object.assign(Object.create(EventEmitter.prototype), {
Greeting: 'hello',
Echo: input => input
});
EventEmitter.call(impl);

await bus.requestName('com.example.Service', 0);
bus.exportInterface(impl, '/com/example/Object', ifaceDesc);

impl.emit('Pinged', 'payload'); // emits the d-bus signal too

A method implementation may return a value, a promise, or throw. A thrown error becomes an error reply, using err.dbusName if set and org.freedesktop.DBus.Error.Failed otherwise. Returning null sends a reply with no body.

What a method returns

One value per complete type in the output signature:

output signaturecomplete typesthe handler returns
''0anything; no reply body is sent
's'1the value — 'hello'
'(si)'1 (a struct)one array — ['name', 3]
'si'2an array of the two — ['name', 3]
'ssss'4an array of the four

'(si)' and 'si' take the same JavaScript value and mean different things on the wire: one struct, versus two separate arguments. Which you want depends on the interface you are implementing — org.freedesktop.Notifications's GetServerInformation, for instance, declares four separate out arguments.

Returning the wrong shape produces an error reply naming the method and the signature it declared, rather than taking the service down:

org.freedesktop.DBus.Error.Failed: com.example.Iface.GetInfo returned a value
that does not match its declared output signature "ssss": …

exportInterface monkey-patches impl.emit so a local emit also sends the signal. org.freedesktop.DBus.Introspectable, .Properties and .Peer are answered for you.

Property access

access defaults to readwrite, which is what a bare signature has always meant. It is advertised in the introspection XML and enforced:

readwritereadwrite
Properties.GetAccessDenied
Properties.SetPropertyReadOnly
Properties.GetAllincludedomittedincluded

PropertiesChanged

Properties.Set emits org.freedesktop.DBus.Properties.PropertiesChanged for you. A write-only property is reported as invalidated rather than broadcast, since there is no value subscribers are allowed to see.

When the service changes a property itself, say so — an ordinary assignment to the implementation object cannot be observed:

impl.Greeting = 'hello again';
bus.emitPropertiesChanged(path, iface.name, { Greeting: impl.Greeting });

emitPropertiesChanged(path, interfaceName, changed, invalidated?) takes signatures from the interface descriptor, so values are marshalled as declared rather than guessed from their JS type. It throws if the interface is not exported at that path, or if a named property is not declared on it.

Receiving one is a subscription on org.freedesktop.DBus.Properties, not on the interface that owns the property — the signal names that interface in its first argument:

const props = await bus
.getService(name)
.getInterface(path, 'org.freedesktop.DBus.Properties');

await props.$subscribe(
'PropertiesChanged',
(interfaceName, changed, invalidated) => {
for (const [prop, value] of Object.entries(changed)) {
console.log(prop, variantValue(value));
}
}
);

changed is an a{sv}, so it is a plain object by default and an array of pairs under plainValues: false — see Values and types. toPlain(changed) reads either, and variantValue() covers the value whichever way variants is set.

The invalidated list names properties whose value changed but is not being sent — write-only ones, or anything expensive to compute. Listing them tells a subscriber to re-read rather than keep a stale value:

bus.emitPropertiesChanged(path, iface.name, {}, ['Expensive']);

Lower-level pieces, if you are not using exportInterface:

method
bus.sendSignal(path, iface, name, signature, args)emit a signal
bus.sendReply(msg, signature, body)reply to a method call
bus.sendError(msg, errorName, errorText)error-reply to a method call
bus.setMethodCallHandler(path, iface, member, [handler, signature])handle one member

Publishing a tree: ObjectManager

org.freedesktop.DBus.ObjectManager is how a service with many objects lets a client fetch all of them, with their properties, in one round trip — and get told when they come and go. BlueZ, NetworkManager, systemd and UDisks all use it, so it is how "list the devices" is spelled on a real bus.

bus.exportObjectManager('/com/example');

bus.exportInterface(hci0, '/com/example/dev0', deviceIface); // InterfacesAdded
bus.exportInterface(hci1, '/com/example/dev1', deviceIface); // InterfacesAdded

bus.unexportInterface('/com/example/dev1'); // InterfacesRemoved

That is the whole API. GetManagedObjects is answered for you, the interface appears in the introspection XML at the manager path, and the two signals are emitted as objects are exported and unexported.

method
bus.exportObjectManager(path)serve ObjectManager at path
bus.unexportInterface(path[, interfaceName])stop serving an object or one interface

Four things worth knowing:

  • A manager reports what is strictly below it, never itself. This is why BlueZ's manager is at / and reports /org/bluez/hci0.
  • Opt-in. A path only manages a tree if you say so — a client has no way to know it can call GetManagedObjects unless the interface is advertised, and answering everywhere would claim a tree at any path someone asked about.
  • Managers may nest. The deepest one containing an object is the one that announces it, so / and /com/example can both be managers without every signal arriving twice at the root.
  • InterfacesAdded announces only what appeared. Re-exporting one interface on an object that already had three is not news about the other three.

Write-only properties are omitted from the reply, exactly as GetAll omits them: there is no value to report and the spec says to leave it out.

unexportInterface returns whether anything was removed, and an object whose last interface goes stops existing rather than lingering as a path with nothing on it.

Watching one: bus.objects()

The client side. GetManagedObjects returns a{oa{sa{sv}}} — three levels of dict with variants at the bottom — and then you have to keep it current from three different signals, matched against the right service. Everyone using BlueZ or NetworkManager writes this, and writes it slightly wrong.

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

bluez.filter('org.bluez.Device1');
// { '/org/bluez/hci0/dev_AA': { Alias: 'Headphones', Connected: false } }

bluez.on('added', (path, interfaces) => {});
bluez.on('removed', (path, interfaceNames) => {});
bluez.on('changed', (path, iface, changed, invalidated) => {});

await bluez.close();
member
view.objects{ path: { interface: { property: value } } }
view.paths()every managed path
view.get(path)one object's interfaces, or undefined
view.filter(interfaceName){ path: properties } for objects implementing it
view.ownerthe unique name currently owning the service
view.close()remove the match rules and stop listening — idempotent

It also implements Symbol.asyncDispose, so the match rules — the resource people forget — can be scoped rather than released by hand:

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

Values are plain in every connection shape. A view whose contents depended on plainValues would be useless to write against.

It subscribes before it fetches. Fetching first leaves a window where an object appears, its InterfacesAdded goes nowhere, and the view is permanently missing it. Signals arriving during startup are buffered and replayed onto the snapshot.

PropertiesChanged is tracked too, so a value stays current and not just the set of objects. Pass { properties: false } to skip it and the match rule it costs. An invalidated property is dropped from the view rather than left at its old value — keeping a value the service has disowned is how a view starts lying; re-read it with Properties.Get.

A 'stale' event fires if the service is replaced or goes away. The view does not resynchronise itself: re-fetching would race whatever the caller is doing with it, and a half-updated tree is worse than a stale one that says so. Make a new view.

If you only want the snapshot, the raw call is fine — just reach for toPlain(), because three levels of pairs is not readable:

const objects = toPlain(await bus.invoke({/* GetManagedObjects */}));
// { '/com/example/dev0': { 'com.example.Device': { Name: 'hci0' } } }

Connection (low-level)

dbus.createConnection(opts) gives the transport with no MessageBus on it. bus.connection is the same object.

const conn = dbus.createConnection();
conn.message({
path: '/org/freedesktop/DBus',
destination: 'org.freedesktop.DBus',
interface: 'org.freedesktop.DBus',
member: 'Hello',
type: dbus.messageType.methodCall
});
conn.on('message', msg => console.log(msg));
member
conn.message(msg)write a message; returns false when the socket buffer is full
conn.end()close the connection
conn.streamthe underlying Duplex
conn.guidthe peer's GUID, after the handshake

message() follows the stream.write() convention. A fast producer must stop on false and resume on 'drain', or messages queue in memory without bound:

if (!conn.message(msg)) {
await new Promise(resolve => conn.once('drain', resolve));
}

Messages written in the same tick are batched into one socket write.

Events

eventargument
connectauthentication succeeded
messageMessagea message arrived
drainthe write buffer emptied; safe to resume
endthe peer hung up
closeError?the transport is fully torn down; pending calls have already been failed
errorErrortransport or protocol failure
handlerErrorErroran exception thrown by one of your message/signal listeners

A protocol error — a malformed or oversized message — is unrecoverable, so the connection is destroyed after error is emitted. handlerError is separate because it is an application bug rather than a connection failure, and the connection stays usable; if nothing is listening for it, the exception is re-thrown asynchronously, matching Node's default for a throwing listener.

messageType: { invalid: 0, methodCall: 1, methodReturn: 2, error: 3, signal: 4 }.


Values and types

Type mapping

D-BuscodeJavaScript
byteynumber
booleanbboolean
int16 / uint16n qnumber
int32 / uint32i unumber
int64 / uint64x tbigint, exactly — a lossy number under returnBigInt: false
doublednumber
stringsstring
object pathostring
signaturegstring
arrayaArray
byte arrayayBuffer — see ayBuffer
struct()Array
dicta{}string keys: a plain object · other keys: Array of [key, value] pairs
variantvread: the value — see variants · write: any value, or Variant to state the type

h (UNIX_FD) is an index into the message's descriptor array — see File descriptors.

Writing 64-bit values accepts a bigint, a number up to 53 bits, a decimal or 0x-prefixed string for the full range, or a Long.js object.

bigint is the one to read carefully. It is not a drop-in for number:

size + 1; // TypeError: Cannot mix BigInt and other types
JSON.stringify({ size }); // TypeError: Do not know how to serialize a BigInt
size > 100; // fine, comparisons work
Number(size); // fine, if you accept the precision loss you already had

docs/migrating-to-2.0.md covers this in full.

Writing a dict as a plain object

Anywhere a dict is expected, a plain object will do:

await iface.SetOptions({ Name: 'widget', Count: 3, Enabled: true });

// the same thing, spelled out
await iface.SetOptions([
['Name', new Variant('s', 'widget')],
['Count', new Variant('i', 3)],
['Enabled', new Variant('b', true)]
]);

Both write identical bytes. The array-of-pairs form is unchanged, and nothing about it infers — only values reached through a plain object do.

Inside an object, the signature of an a{sv} value is inferred:

JavaScriptinferred
strings
booleanb
integer within int32i
integer outside int32x
any other numberd
bigintx
Buffer / TypedArrayay
Array (homogeneous)a + element type
plain objecta{sv}

Variant overrides it, and is how you reach the types inference does not produce — u, y, o, g, structs, av:

await iface.SetOptions({ Count: new Variant('u', 3) }); // uint32, not int32

Two things to know:

  • Inside an object an array is an array, never a [signature, value] pair. { v: ['s', 'hello'] } is an array of two strings; use new Variant('s', 'hello') for an explicitly typed value.
  • Inference refuses to guess rather than picking something arbitrary: an empty array, a mixed array, null, undefined, NaN and anything it does not recognise all throw, naming what to do instead. A d-bus array is homogeneous, so [1, 'a'] has no signature.

A class instance is not treated as a dict — only objects whose prototype is Object.prototype or null. Being wrong about that would write a garbled message instead of failing.

Reading values: plainValues

A variant reads as the value itself and a string-keyed dict as a plain object. plainValues: false reads the classic shapes instead.

signatureplainValues (default)plainValues: false
vvalue[signatureTree, [value]]
a{sv}{ key: value }array of pairs, values variants
a{ss}{ key: value }array of pairs
a{us}pairs — see belowarray of pairs
a(ss)array of arrays, not a dictarray of arrays

Reading only. The marshaller takes plain objects and Variant, so a value read this way can be written straight back out — including passing one from one service to another.

A dict whose keys are not strings stays as pairs. A JavaScript object key is always a string, so a{us} read as an object would turn the key 1 into '1', and a 64-bit key would stringify and lose precision on the way back. Quiet corruption is worse than an inconvenient shape.

Reading a variant this way discards its signaturevariantSignature() returns undefined for it. That is usually what you want, and when it is not, see variants below.

Both sides of a conversation see the same shapes, since a service reads its own method arguments through the same parser. If you change this, change it on both.

variantValue() and toPlain() read either shape and are the identity under this one, so code written against them works whichever is in force. For code that cannot be migrated yet, dbus-native/compat restores the classic shapes wholesale:

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

Getting the type back: variants

Sometimes the value is not enough. A tool that prints a reply has to say variant u 501 rather than 501, and a service handed an a{sv} may need to know what its caller actually sent. Both used to mean reading the parser's internal tree, which is the thing this replaced.

variants decides how a v comes back, independently of the dict shape:

valuea v reads asvariantSignature()
'tree'[signatureTree, [value]]the signature
'plain'valueundefined
'wrap'Variant(sig, value)the signature

It follows plainValues when unset — 'plain' by default, 'tree' under plainValues: false.

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

const props = await iface.$readProp('Metadata');
props.Volume.signature; // 'd'
props.Volume.value; // 0.5
variantValue(props.Volume); // 0.5 -- the accessors understand it

That combination — plain dicts whose values still carry their types — is what dbus-native call uses, and it is the recommended shape for anything that inspects or forwards values rather than just consuming them.

A Variant is a better carrier than the tree in three ways: it prints as Variant('u', 501) instead of a wall of parse-tree objects, variantValue() and toPlain() already read it, and the marshaller accepts it, so a value read this way can be sent straight back out without unwrapping. The tree could do none of those.

The signature is the one the sender wrote, never one re-derived from the value: y, n, q, i, u and d all arrive as a JavaScript number, so inferring it back would be a guess presented as type information.

A router is the case that needs all of this at once. lib/broker.js reads with variants: 'wrap', plainValues: false and returnBigInt: true — every convenience shape turned off — because forwarding means unmarshalling a message and marshalling it again, and each of those conveniences discards something the next hop was entitled to: the variant's signature, duplicate dict keys, and the low bits of a 64-bit integer respectively.


A variant read off the wire can be written straight back, so a value can be passed from one service to another without unwrapping it:

const opts = await source.GetOptions();
await sink.SetOptions(opts);

Under 'plain' the type is genuinely gone, so writing the value back infers a signature from it the same way a value inside a plain object does — which means u, i and d all come back out as whatever the number implies. Read with 'wrap' when a value has to arrive typed exactly as it was sent.

ayBuffer controls byte arrays: true (default) copies into a Buffer; 'view' returns a Buffer sharing memory with the message, which avoids the copy but keeps the whole message alive for as long as you hold the value — a 4-byte ay from a 4 MB message pins 4 MB; false gives a plain array of numbers.

Helpers

const {
Variant,
variantValue,
variantSignature,
toPlain
} = require('dbus-native');
function
variantValue(v)the value inside a variant, in any of the three shapes; identity on a plain value
variantSignature(v)the variant's signature, or undefined once flattened
toPlain(v)recursively: dicts to objects, variants unwrapped; no-op on plain values
new Variant(signature, value)an explicitly typed value, for writing
const udi = variantValue(entry); // 'tree': entry[1][0]; 'plain': entry
const props = toPlain(dict); // classic: array of pairs; now: identity

bus.invoke({
/* … */ signature: 'ssv',
body: [iface, 'Greeting', new Variant('s', 'hello')]
});

toPlain only converts arrays the parser tagged as dicts, so a(ss) — an array of two-string structs — is left alone. A shape-based heuristic cannot tell those two apart, which is why the parser tags them rather than guessing.


Names

Object paths, interface names, error names, member names and bus names each have their own rules in the specification, and they are easy to confuse:

kindshapenotes
object path/, or /-separated [A-Za-z0-9_] elementselements may start with a digit; no .
interface nametwo or more .-separated [A-Za-z_][A-Za-z0-9_]*no -, no leading digit, ≤ 255 bytes
error nameas interface names
member nameone [A-Za-z_][A-Za-z0-9_]* elementno dots, ≤ 255 bytes
bus name:1.23, or an interface name that may contain -≤ 255 bytes

Property names are the odd one out: the specification does not give them a rule at all. A method or signal name is a header field the bus itself parses, but a property name is only ever a string argument to Properties.Get/Set, and the introspection DTD declares the attribute as CDATA. So a property may additionally contain -, which is the GObject convention and which GDBus, sd-bus and python-dbus all read and write without complaint:

kindshapenotes
property nameone [A-Za-z_][A-Za-z0-9_-]* elementmember name, plus -; ≤ 255 bytes

The rest of the member charset is kept so the name needs no escaping in the introspection XML, and so typos — a space, a dot, a stray quote — are still caught.

These are enforced on what you send, because an invalid name produces a message no peer can route — and for a signal, which gets no reply, that fails silently:

wherechecks
bus.exportInterface()the path, iface.name, every method and signal name, and every property name against the looser rule above
bus.sendSignal()the interface and member name
bus.sendError()the error name
marshalling an othe object path, including every message's path

All of them throw an Error naming the rule that was broken. Incoming names are not checked — be strict in what you send, lenient in what you accept.

The predicates are exported for names built at runtime, where checking beats catching:

const { isValidInterfaceName, isValidObjectPath } = require('dbus-native');

if (!isValidObjectPath(`/com/example/${deviceId}`)) {
// a device id with a '-' in it would be rejected at export
}

isValidObjectPath, isValidInterfaceName, isValidErrorName, isValidMemberName, isValidPropertyName and isValidBusName all take any value and return a boolean.


Errors

Every failure is an Error. All of these extend DBusError, which extends Error.

classraised whencode
DBusErrorthe call returned an error reply
TimeoutErrorno reply within the timeoutETIMEDOUT
AbortErrorcancelled through an AbortSignalABORT_ERR
ConnectionClosedErrorthe connection went away with the call pendingECONNCLOSED
UnknownInterfaceErrorthe object does not implement that interface
property
err.messagethe error text, or the D-Bus error name when the body was empty
err.dbusNamee.g. org.freedesktop.DBus.Error.ServiceUnknown
err.bodythe raw reply arguments
err.replythe full reply message
try {
await iface.Method();
} catch (err) {
if (err.dbusName === 'org.freedesktop.DBus.Error.ServiceUnknown') {
/* … */
}
if (err instanceof dbus.TimeoutError) {
/* … */
}
}

TimeoutError and AbortError both carry dbusName: 'org.freedesktop.DBus.Error.NoReply', so code switching on dbusName treats a local timeout and a remote no-reply the same way. AbortError.cause is the signal's reason.

Before 0.7 a failed call delivered the raw message body array — docs/migrating-to-0.7.md, and require('dbus-native/compat').toClassicError(err) reconstructs it.


Diagnostics

Instrumented through node:diagnostics_channel, so it costs nothing when nobody is subscribed.

channel name to subscribe topayload
dbus:message:send{ message } — every message written
dbus:message:receive{ message } — every message read
tracing:dbus:call:startthe call context (below)
tracing:dbus:call:endthe same context, now with result or error
tracing:dbus:call:errorthe same context, with error

The call context is { destination, path, interface, member, signature, result?, error? }, and is the same object across start and end, so it can be used as a Map key to time a call.

The tracing: prefix is Node's, not ours — diagnostics_channel.tracingChannel('dbus:call') names its channels tracing:${name}:${event}.

const dc = require('node:diagnostics_channel');
dc.subscribe('tracing:dbus:call:error', ctx =>
console.error(`${ctx.member} failed:`, ctx.error.message)
);

CLI

command
callcall a method, the way dbus-send does
get, setread or write a property
listthe names on the bus
typesTypeScript declarations for a service
introspecta service's raw introspection XML
codemodrewrite your source for a breaking change
lintreport reads of the value shapes that changed in 0.14.0

Talking to the bus

npx dbus-native list
npx dbus-native call --dest org.freedesktop.DBus \
/org/freedesktop/DBus org.freedesktop.DBus.ListNames

npx dbus-native get --system --dest org.freedesktop.UPower \
/org/freedesktop/UPower org.freedesktop.UPower DaemonVersion

Arguments use dbus-send's type:value form, so a command line you already have can be pasted in:

formsignature
string: objpath: signature:s o g
byte: boolean:y b
int16: uint16: int32: uint32:n q i u
int64: uint64: double:x t d
array:string:a,b,cas
dict:string:uint32:width,800a{su}
variant:int32:42v
dict:string:variant:urgency,byte:1a{sv}
array:variant:int32:1,string:twoav

variant works as a container element type because a type:value pair contains no comma, so each element can carry its own. That matters because a{sv} is the most common dict on the bus — Notify's hints, Properties.GetAll, NetworkManager's settings — and dbus-send cannot express it:

npx dbus-native call --dest org.freedesktop.Notifications \
/org/freedesktop/Notifications org.freedesktop.Notifications.Notify \
string:my-app uint32:0 string: string:Summary string:Body \
array:string: dict:string:variant:urgency,byte:1 int32:2000

Each value is range-checked before anything is sent, and the message names the type you typed rather than the signature character: 256 is out of range for y (0..255). 64-bit values are carried as BigInt and printed exactly, in both directions — a tool for inspecting values must not round them.

For what that form cannot express — a struct, nesting past one level, a value containing a comma — give the signature and the body directly:

npx dbus-native call --dest com.example.Service /com/example/Obj \
com.example.Iface.Configure \
--signature 'a(si)' --body '[[["first", 1], ["second", 2]]]'

Other flags: --system, --address, --json (plain shapes, for a pipeline), --timeout, --no-reply, --no-auto-start, and for list, --activatable and --all. A failed call exits non-zero with the error name as well as its text, since UnknownMethod and AccessDenied want different responses.

Generating and checking code

npx dbus-native types --system \
--service org.freedesktop.NetworkManager \
--path /org/freedesktop/NetworkManager \
--out src/generated/network-manager.d.ts

npx dbus-native introspect --service org.example --path /org/example
npx dbus-native codemod errors-to-error-objects --dry src/
npx dbus-native lint src/

lint exits non-zero when there are findings, so it can gate CI. --exit-zero reports without failing; --rule DBUS_DEP0002 selects individual codes. It never rewrites — reading a variant is an index chain and nothing in the source says what the value is, so it narrows the problem to a reviewed list rather than guessing. Findings marked (possible) are heuristic.

dbus2js was removed in 0.14.0 (DBUS_DEP0005).

See the README for the full flag list, and docs/deprecations.md for every deprecation code.


TypeScript

Types ship with the package; there is no @types/ to install. They are hand-written and checked in CI against a usage fixture, so they cannot drift from the API without the build failing.

import dbus = require('dbus-native');
import { MessageBus, DBusInterface, DBusError, Variant } from 'dbus-native';

getInterface takes an optional type argument for a checked remote surface:

interface Player extends DBusInterface {
PlayPause(): Promise<void>;
}
const player = await bus
.getService('org.mpris.MediaPlayer2.vlc')
.getInterface<Player>(
'/org/mpris/MediaPlayer2',
'org.mpris.MediaPlayer2.Player'
);

dbus-native types generates those interfaces from a live service.