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 · Connection options
- Callbacks and promises
- MessageBus · Proxy API
- Exporting a service
- Connection
- Values and types · Errors
- Diagnostics · CLI
Entry points
const dbus = require('dbus-native');
| function | returns | |
|---|---|---|
dbus.sessionBus(opts?) | MessageBus | connects to $DBUS_SESSION_BUS_ADDRESS |
dbus.systemBus() | MessageBus | connects to $DBUS_SYSTEM_BUS_ADDRESS, or the standard system socket |
dbus.createClient(opts?) | MessageBus | connects to whatever opts describes |
dbus.createConnection(opts?) | DBusConnection | the transport alone, with no MessageBus on top |
dbus.createServer(handler?) | DBusServer | accepts peer-to-peer connections — see below |
dbus.createBroker(opts?) | DBusBroker | a 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.id | the server GUID and the bus id |
Events: listening, connection, hello, disconnect, error,
clientError.
It implements org.freedesktop.DBus — Hello, 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
| option | type | default | |
|---|---|---|---|
busAddress | string | $DBUS_SESSION_BUS_ADDRESS | encoded address; ;-separated alternatives are tried in order |
socket | string | — | unix socket path |
port | number | — | TCP port |
host | string | localhost | TCP host |
stream | Duplex | — | use an already-connected stream |
authMethods | string[] | ['EXTERNAL', 'DBUS_COOKIE_SHA1', 'ANONYMOUS'] | tried in order |
direct | boolean | false | skip the opening Hello, for peer-to-peer connections |
server | boolean | false | act as the server side of the handshake |
timeout | number | 25000 | ms every call waits for its reply; 0 disables — see below |
ayBuffer | true | false | 'view' | true | how ay comes back — see Values |
maxMessageSize | number | 128 MiB | reject a message declaring more than this |
returnBigInt | boolean | true | read x/t as native bigint, exactly — see below |
plainValues | boolean | true | read variants and dicts as plain values — see below |
variants | 'tree'|'plain'|'wrap' | follows plainValues | how a v comes back; 'wrap' keeps the signature — see below |
reconnect | boolean | object | false | reconnect 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 body | resolves with |
|---|---|
| no values | undefined |
| one value | the value |
| two or more | an 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:
| field | type | |
|---|---|---|
destination | string | bus name to call |
path | string | object path |
interface | string | interface name |
member | string | method name |
signature | string | signature of body; omit when there are no arguments |
body | unknown[] | the arguments |
type | number | defaults to messageType.methodCall |
flags | number |
options:
| option | type | |
|---|---|---|
timeout | number | ms to wait; 0 disables. Defaults to the client's timeout |
signal | AbortSignal | cancels 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.
| method | resolves 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.canPassFds | whether the transport declared the capability |
connection.unixFdsAgreed | whether 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_FDSheader claims, which is how libdbus does it too.SCM_RIGHTSgives 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.
| event | on | |
|---|---|---|
reconnecting {attempt, delay, cause} | connection | a retry is scheduled |
reconnect | connection | the transport is back, nothing restored yet |
reconnected {name, names} | bus | names and match rules are back |
reconnectError | bus | reconnected, but restoring them failed |
reconnectFailed | connection | retries 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.connection | the underlying DBusConnection |
bus.name | this client's unique name, once Hello has been answered |
bus.signals | EventEmitter 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.Name | read 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/$off | signals, fire and forget |
proxy.$as(interfaceName) | the underlying interface, for anything else |
proxy.$service, $path | what it stands for |
proxy.$interfaces | the interfaces it dispatches across |
proxy.$nodes | child 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);
| call | returns |
|---|---|
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.name | the object path — always the one you asked for |
obj.service | the owning DBusService |
obj.proxy | every introspected interface, by name |
obj.nodes | the 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 throwsUnknownInterfaceError, 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/setare functions, so a value can be derived rather than stored, and asetstill getsPropertiesChangedemitted for it automatically. - Mistakes are caught where they were written. An unknown key, a
seton a read-only property, an invalid member name — all rejected bydefineInterface()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 signature | complete types | the handler returns |
|---|---|---|
'' | 0 | anything; no reply body is sent |
's' | 1 | the value — 'hello' |
'(si)' | 1 (a struct) | one array — ['name', 3] |
'si' | 2 | an array of the two — ['name', 3] |
'ssss' | 4 | an 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:
read | write | readwrite | |
|---|---|---|---|
Properties.Get | ✓ | AccessDenied | ✓ |
Properties.Set | PropertyReadOnly | ✓ | ✓ |
Properties.GetAll | included | omitted | included |
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
GetManagedObjectsunless 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/examplecan both be managers without every signal arriving twice at the root. InterfacesAddedannounces 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.owner | the 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.stream | the underlying Duplex |
conn.guid | the 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
| event | argument | |
|---|---|---|
connect | — | authentication succeeded |
message | Message | a message arrived |
drain | — | the write buffer emptied; safe to resume |
end | — | the peer hung up |
close | Error? | the transport is fully torn down; pending calls have already been failed |
error | Error | transport or protocol failure |
handlerError | Error | an 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-Bus | code | JavaScript |
|---|---|---|
| byte | y | number |
| boolean | b | boolean |
| int16 / uint16 | n q | number |
| int32 / uint32 | i u | number |
| int64 / uint64 | x t | bigint, exactly — a lossy number under returnBigInt: false |
| double | d | number |
| string | s | string |
| object path | o | string |
| signature | g | string |
| array | a | Array |
| byte array | ay | Buffer — see ayBuffer |
| struct | () | Array |
| dict | a{} | string keys: a plain object · other keys: Array of [key, value] pairs |
| variant | v | read: 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:
| JavaScript | inferred |
|---|---|
string | s |
boolean | b |
| integer within int32 | i |
| integer outside int32 | x |
any other number | d |
bigint | x |
Buffer / TypedArray | ay |
Array (homogeneous) | a + element type |
| plain object | a{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; usenew 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,NaNand 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.
| signature | plainValues (default) | plainValues: false |
|---|---|---|
v | value | [signatureTree, [value]] |
a{sv} | { key: value } | array of pairs, values variants |
a{ss} | { key: value } | array of pairs |
a{us} | pairs — see below | array of pairs |
a(ss) | array of arrays, not a dict | array 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 signature — variantSignature()
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:
| value | a v reads as | variantSignature() |
|---|---|---|
'tree' | [signatureTree, [value]] | the signature |
'plain' | value | undefined |
'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:
| kind | shape | notes |
|---|---|---|
| object path | /, or /-separated [A-Za-z0-9_] elements | elements may start with a digit; no . |
| interface name | two or more .-separated [A-Za-z_][A-Za-z0-9_]* | no -, no leading digit, ≤ 255 bytes |
| error name | as interface names | |
| member name | one [A-Za-z_][A-Za-z0-9_]* element | no 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:
| kind | shape | notes |
|---|---|---|
| property name | one [A-Za-z_][A-Za-z0-9_-]* element | member 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:
| where | checks |
|---|---|
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 o | the 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.
| class | raised when | code |
|---|---|---|
DBusError | the call returned an error reply | — |
TimeoutError | no reply within the timeout | ETIMEDOUT |
AbortError | cancelled through an AbortSignal | ABORT_ERR |
ConnectionClosedError | the connection went away with the call pending | ECONNCLOSED |
UnknownInterfaceError | the object does not implement that interface | — |
| property | |
|---|---|
err.message | the error text, or the D-Bus error name when the body was empty |
err.dbusName | e.g. org.freedesktop.DBus.Error.ServiceUnknown |
err.body | the raw reply arguments |
err.reply | the 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 to | payload |
|---|---|
dbus:message:send | { message } — every message written |
dbus:message:receive | { message } — every message read |
tracing:dbus:call:start | the call context (below) |
tracing:dbus:call:end | the same context, now with result or error |
tracing:dbus:call:error | the 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 | |
|---|---|
call | call a method, the way dbus-send does |
get, set | read or write a property |
list | the names on the bus |
types | TypeScript declarations for a service |
introspect | a service's raw introspection XML |
codemod | rewrite your source for a breaking change |
lint | report 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:
| form | signature |
|---|---|
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,c | as |
dict:string:uint32:width,800 | a{su} |
variant:int32:42 | v |
dict:string:variant:urgency,byte:1 | a{sv} |
array:variant:int32:1,string:two | av |
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.