RELEASE_PLAN.md
How the design in BIG_FUTURE_PLANS.md gets delivered without forking the ecosystem.
Written 2026-07-29. This is the sequencing plan; the API design lives in the other document and the current backlog in ROADMAP.md.
The decision
Track 1 is dropped. Not deferred — the premise it rested on turned out to be false, and the work is what disproved it. See The
/nexttrack below. Track 2 shipped in full.
Two tracks, one package, no new npm name.
Dropped.dbus-native/next— the modern API from BIG_FUTURE_PLANS, developed as a subpath export alongside the existing one. No migration required to keep using the classic surface.- A short series of major releases that fix the genuine defects in the classic API — variants, errors, 64-bit — each one narrow enough to document and tool properly. ✅
The second track is the departure from "add alongside forever". The reason is
the flag-sprawl failure mode: ayBuffer: true|false|'view', ReturnLongjs,
plus a hypothetical returnBigInt and variants: 'plain'|'tree' gives a
combinatorial test matrix and documentation that has to explain the wrong way
first. A deliberate break, well supported, is cheaper for everyone than
permanent duality.
Why both tracks and not just the majors? Because some of the design —
await using, proxies, async-iterable signals — is not a modified version of
the current API, it is a different shape. Forcing it through the classic
surface would compromise it. And /next can move fast under 0.x rules while
the classic track keeps its semver promises.
Why this order
Each major is chosen so its blast radius is small enough to tool for, and so it unblocks the next one.
None of these shipped under the names below. Every one of them landed as a
0.xminor, because under semver a pre-1.0 minor is already the breaking bump and 1.0.0 is a statement about stability worth making deliberately. The planned names are kept here because the rest of this document argues in them; the shipped as column is what actually exists. Anything user-facing should cite the shipped version, not the planned one.
| release | shipped as | theme | blast radius | migration mechanism |
|---|---|---|---|---|
| 0.6 | 0.6.0 | preparation, all additive | none | — |
| 1.0 | 0.7.0 | errors are Errors ✅ | error-handling paths only | codemod + forward-compatible shim |
| 2.0 | 0.14.0 | the type system ✅ | every value read | accessors + lint + compat wrapper |
| 3.0 | 0.13.0 | lifecycle and cancellation ✅ | connection setup/teardown | codemod + deprecations |
| 4.0 | — | /next default | imports | codemod |
Errors come before types because a rejected promise has to carry an Error for
promises to be worth anything, and because it touches only catch blocks
rather than every value in the program. Types come before lifecycle because it
is the highest-value change and the one people are actually waiting for. ESM
came last because it is the one break with no functional benefit — and having
got there, it turned out not to be worth doing at all: see §4.0 below and
BIG_FUTURE_PLANS §4.1.
0.6 — preparation (no breaking changes)
The most important release in the plan. Everything here is additive, and its job is to let people write code that works both before and after each subsequent major.
Promises alongside callbacks. #295
is +15/−3 and already does this: return a promise when no callback is given.
Merge it, extend it to bus.invoke and the proxy surface.
Closes #9,
#10.
Forward-compatible error properties. Today a failed call delivers the
message body — an array. In 0.6 that array also carries the properties the 1.0
DBusError will have:
// 0.6: the array is still an array, but gains properties
Object.assign(body, {
name: 'DBusError',
message: body[0] ?? errorName,
dbusName: errorName
});
So this works identically in 0.6 and in 1.0:
bus.invoke(msg, err => {
if (err?.dbusName === 'org.freedesktop.DBus.Error.ServiceUnknown') {
/* ... */
}
});
Users migrate at their own pace, on a released version, with no flag day.
Forward-compatible value accessors. The same trick for the type system. Exported from 0.6, these work on both the classic tree shape and the 2.0 plain shape:
import { variantValue, toPlain } from 'dbus-native';
const udi = variantValue(entry); // classic: entry[1][0]; 2.0: entry
const props = toPlain(dict); // classic: array of pairs; 2.0: identity
Code written against these survives 2.0 untouched. This is the single most useful thing in the plan, and it costs almost nothing.
Deprecation warnings with codes, following Node's own convention:
(node:12345) [DBUS_DEP0002] DeprecationWarning: Reading a variant as
[signature, [value]] is deprecated and changes in 2.0. Use
variantValue(). See https://github.com/sidorares/dbus-native/blob/master/docs/deprecations.md#dbus_dep0002
Each code gets a documentation anchor, and --throw-deprecation turns them
into thrown errors so a consumer's own test suite locates the call sites:
node --throw-deprecation --test
Correction to an earlier draft of this plan: that only works for
deprecations whose trigger is a call the user makes — passing ReturnLongjs,
calling connection.end(). It does not work for the value-shape changes in
2.0. A warning there would have to fire inside the parser when the value is
read, so the stack would point at this library rather than at the line that
unpacks the value: it would tell you that you are affected without telling you
where. Those codes are therefore documentation-only, and finding call sites is
the lint rule's job. docs/deprecations.md labels each code runtime or
documentation so the distinction is visible at the point of use.
Warnings fire once per code per process, so normal runs stay quiet.
Also in 0.6, all additive: AbortSignal on calls,
diagnostics_channel instrumentation, and a hand-written index.d.ts
(#276). The first
— never shipped, and the track is dropped./next preview
1.0 — errors are Errors
Shipped as 0.7.0. Under semver a
0.xminor is already the breaking bump, so the content below did not need a 1.0 to land, and1.0.0is a statement about stability better made deliberately — after the ecosystem coordination below — than as a side effect of the first break in the series. Everything in this section is done, codemod included; see docs/migrating-to-0.7.md. Read "1.0" here and in the table above as "the errors release".One correction to the sketch below: jscodeshift is a devDependency, not a runtime one. Shipping an AST toolchain in every install of a d-bus library to support a one-off migration is the wrong trade; the transform itself ships, and the CLI runs it through
npxwhen the consuming project has no jscodeshift of its own.
Closes #39, #178, #207, #208. Absorbs #213.
| before | after |
|---|---|
err is the message body array | err is a DBusError |
err is [] for an empty body | err.message is the D-Bus error name |
missing interface → (null, undefined) | rejects with UnknownInterfaceError |
| connection dies → pending callbacks dropped silently | all reject with ConnectionClosedError |
Anyone who followed the 0.6 warnings already uses err.dbusName and
err.message, and needs no change at all.
Codemod for those who did not:
npx dbus-native codemod errors-to-error-objects src/
bus.invoke(msg, (err, result) => {
- if (err) return reject(new Error(err[0]));
+ if (err) return reject(err);
});
It rewrites err[0] to err.message and unwraps new Error(err[0]), but only
inside callbacks it can identify as D-Bus callbacks by call-site shape.
Anything ambiguous is left alone and reported, because a codemod that guesses
wrong in an error path is worse than one that does nothing.
Escape hatch: dbus-native/compat exports toClassicError(err) returning
the old array. Deliberately in a subpath, not an option on the core — it is
greppable, obviously temporary, and deletable in one commit.
2.0 — the type system
Shipped. Same reasoning as the errors release above: a
0.xminor is already the breaking bump, so this landed without needing a2.0.0tag.plainValuesandreturnBigIntdefault totrue;variantsfollowsplainValues, so avreads as its value. Every old shape is still an option, andwithClassicTypesstill restores all three at once — theclassicrun of the shape gate exists to keep that true.Two things the plan below did not anticipate, both found by the gate rather than by reasoning:
- A router has to opt out of every convenience shape, not just the lossy one.
lib/broker.jsalready read 64-bit exactly; it still lost a variant's signature, and deliveredVariant('u', 9)to the next hop asi. Only thewraprun of the gate could see it, because the other two have nowhere to put the signature.- The plain shape was unwritable at a bare
v. Reading gave a plain value and writing one back threw "variant data should be [signature, data]", so the round-trip property the shape is sold on did not hold. The fix was to infer atvexactly as a value insidea{sv}already did.
Closes #3, #67, #91, #114, #132, #147, #248. Supersedes #143, #252.
The one people are waiting for, and the one needing the most support.
| D-Bus | 1.x | 2.0 |
|---|---|---|
v | [parsedTree, [value]] | the value; Variant when explicitly requested |
a{sv}, a{ss} | array of pairs | plain object |
x, t | lossy number, or Long.js under a flag | bigint |
ay | Buffer | Buffer — see below |
// 1.x
const udi = dict.find(([k]) => k === 'Udi')[1][1][0];
// 2.0
const { Udi } = await device.props.$all;
ay should stay a Buffer. BIG_FUTURE_PLANS proposed Uint8Array on
web-standards grounds, and having thought about it for delivery I think that is
wrong and I would drop it. Buffer is a Uint8Array subclass, so anything
accepting the latter already accepts the former, while the reverse is not true:
buf.toString('utf8') is used constantly and does not exist on a plain
Uint8Array. Breaking it costs real user code and buys almost nothing in a
Node-only library.
bigint is the sharp edge, and the docs must lead with it rather than bury
it. 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
Every 64-bit value in a program that touches JSON or arithmetic needs attention. This is the single largest source of migration pain in the plan and deserves its own guide page.
Migration mechanisms, in the order users should reach for them:
-
The 0.6 accessors. Code using
variantValue()/toPlain()needs no change. This is why 0.6 matters. -
A lint rule, not a codemod, for the residual cases. Reading a variant is
result[1][1][0]— an index chain a codemod cannot safely rewrite because it has no idea what the value is. So we flag rather than transform:npx dbus-native lint src/src/net.js:42 DBUS_DEP0002 variant index chain `[1][1][0]`-> variantValue(), or a plain property read after 2.0Being honest about this is important: there is no complete codemod for 2.0. Anyone promising one has not thought about it. The tooling narrows the problem to a reviewed list of call sites.
Shipped ahead of 2.0, so the list can be worked through on a released version. The dict rules are marked
(possible)in the report rather than asserted:for (const [k, v] of xs)is also ordinary JavaScript, and a linter that cries wolf gets switched off.Object.entries()and the other standard pair producers are excluded outright. -
dbus-native/compatfor code that cannot be migrated yet:import { withClassicTypes } from 'dbus-native/compat';const bus = withClassicTypes(sessionBus()); // 1.x shapes, on 2.0Shipped ahead of 2.0, where it is a no-op, so the import can go in before the flag day rather than during it.
Scoped to a connection, not to a reference. The first draft of this section said "a wrapper, not a mode", which turned out not to be buildable: it asks the parser for the old shapes rather than converting values after the fact, because
plainValuesdiscards a variant's inner signature as it reads it. Nothing downstream can reconstruct[signatureTree, [value]]without inventing the tree, and a fabricated signature is worse than none. So it configures the bus it is given and returns it — unrelated code with its own bus is unaffected, but two references to this bus both see 1.x.Note it restores the old lossiness along with the old shapes:
x/tcome back as a roundednumberagain. That is the point for code that expects a Number, and a trap for anyone reaching for it to silence a BigInt error without reading further.
3.0 — lifecycle and cancellation
Shipped, all three, and none of them needed a release of their own. The first two landed additively across 0.7–0.13; the third is breaking and went out with the type-system release, since a consumer upgrading anyway would rather read one migration guide than two.
-
✅
Symbol.asyncDisposeon connections, subscriptions and name registrations. -
✅
connection.end()→await bus.close(), which flushes pending writes and fails in-flight calls cleanly instead of throwingERR_STREAM_WRITE_AFTER_END— which is what #20 did before the audit. -
✅ A default call timeout. This one is breaking in an unusual direction: it makes previously-hanging calls start failing. That is the point, and
timeout: 0opts out per call or per client.25 seconds, which is what libdbus, GDBus and sd-bus all use — so a call that hits this deadline would have hit theirs at the same point, and the change brings the package into line rather than inventing a policy.
Building it turned up a case the sketch above did not consider: a message carrying
NO_REPLY_EXPECTED. A deadline there would report aTimeoutErrorfor a message that did exactly what it was told, so those get none — and since there is no reply to wait for,invokenow settles them as soon as the message is written. That also fixes a leak nobody had noticed: it used to register a pending-call entry against a serial that could never arrive, one per call, for the life of the connection.
Codemoddable: bus.connection.end() → await bus.close() is a mechanical
rewrite.
Note the library implements Symbol.asyncDispose regardless of the consumer's
Node version — only the using keyword needs Node 24, and that is the user's
choice, not a floor we impose. The engines floor moved to 22.12.0 with the
type-system release rather than here, because Node 20 reached end of life on
2026-04-30 and 22.12 is where require(esm) becomes available — see
BIG_FUTURE_PLANS §4.
4.0 — ESM, and /next becomes the default
Both halves are dropped, so this release does not exist. The
/nexthalf went with the track itself — there is nothing to make the default. The ESM half is below.The ESM half is dropped. Not deferred — measured. An ESM consumer can already import the CJS package completely: default import, every named export, subpaths, deep subpaths, and
instanceofacross the boundary. So ESM-only buys nothing, while costingrequire()for every consumer on Node < 22.12 and anyone whose bundler does not implementrequire(esm). See BIG_FUTURE_PLANS §4.1 for the measurements.The dual-package hazard below is still real and is still a good reason never to publish one. It was never a reason to abandon CJS.
dbus-nativeresolves to the modern API; the classic surface moves todbus-native/classicand stays supported for a defined period.- ESM-only with a real
exportsmap. Not dual: a dual package means two copies ofVariant, andinstanceoffailing across them is a genuine hazard for a library whose whole point is value wrapping. - Node floor rises to whatever
/nextneeds.
Deferrable. If appetite is low, 3.0 and 4.0 merge, or 4.0 waits a year — it is the only break in the plan with no functional payoff.
The /next track
Dropped, and never built. There is no
./nextin theexportsmap and no such directory. The reasoning below is kept because the refutation only makes sense against it.
The case for a second surface was this, from The decision:
some of the design —
await using, proxies, async-iterable signals — is not a modified version of the current API, it is a different shape. Forcing it through the classic surface would compromise it.
All of it went through the classic surface, additively, uncompromised:
| predicted not to fit | shipped as | broke anything? |
|---|---|---|
await using | bus.close(), bus.watch(), bus.ownName() | no |
| proxies | bus.proxy(), beside getService() | no |
| async-iterable signals | proxy.$signal(), bounded; $watch() for events | no |
| declarative service defs | defineInterface(), compiles to the old arrays | no |
Four for four against the prediction. The one thing that did need a break —
the value shapes — needed it on the classic surface, where a second track would
not have helped: /next would have carried the new shapes for new code while
leaving every existing consumer exactly where they were.
So the subpath would have bought nothing that was not already had, and cost the
top risk in this document — two live surfaces, which is how dbus-next and
this package both ended up half-maintained. Same shape of conclusion as the
ESM decision in §4.0: the thing that looked necessary turned out, on contact
with the work, not to be.
What survives is the part that was right for a different reason: whatever ships must be this package. That is now true by construction rather than by discipline, because there is only one surface to ship.
If a future design genuinely cannot fit, this decision is one commit to revisit and the design is in git. Nothing is stranded by dropping it; a half-built subpath published under a compatibility promise would have been.
Machinery to build first
Ordered by how much they unblock:
docs/deprecations.mdwithDBUS_DEP0001…anchors, and adeprecate(code, message)helper that warns once per code.- The 0.6 forward-compatible accessors —
variantValue,toPlain, error properties. Small, and every later migration leans on them. npx dbus-native codemod <name>on jscodeshift, with fixture-based tests. Codemods ship in the package so the version that breaks you also contains the fix.npx dbus-native lintfor the patterns codemods cannot safely rewrite. ast-grep rules are enough; this does not need to be a real ESLint plugin initially. Done — built on jscodeshift rather than ast-grep, reusing the plumbing the codemod already needed, so there is one dependency story for both rather than two.- A migration guide per major —
docs/migrating-to-1.mdand so on — with a before/after table for every changed behaviour, not prose.
Ecosystem coordination
The download split makes this the highest-leverage item in the plan, and it is not a technical one:
| package | weekly | share |
|---|---|---|
@homebridge/dbus-native | 40,827 | 61% |
dbus-next | 19,273 (dormant since 2022) | 28% |
dbus-native | 7,370 | 11% |
Roughly nine in ten users of this codebase consume it through the Homebridge fork. A major series they do not follow is not a migration, it is a permanent split.
Concretely: talk to them before 1.0 ships, not after. Their fork exists because
upstream went quiet; upstream is now demonstrably not quiet, their three deltas
are already resolved here, and the long.js ARMv6 workaround that forced them
to vendor a fork disappears at 2.0 when bigint lands. Offer them a say in the
1.0 error shape — the cost of that conversation is an email and the cost of
skipping it is the whole plan.
Separately: #263 should be closed with a statement of direction. It has been ambiguous since 2019, and this plan is the answer to it.
Risks
The 0.6 accessors go unused. They only pay off if people adopt them, which means the deprecation warnings have to actually fire in the paths that matter and the docs have to lead with them. If 0.6 lands quietly, 2.0 hurts.
Major fatigue. Four majors in a package that shipped one release in four years is a lot of churn. Mitigation: no fixed schedule, ship each when it is ready and documented, and be willing to merge 3.0 and 4.0.
Retired — the track was dropped before anything was published
under it, which is the only mitigation that actually works. The mitigation
written here ("the classic track delivers value independently") turned out to
be the whole answer: the classic track delivered everything, so there was
nothing left for a second surface to carry./next half-finished. The failure mode of #251, and of dbus-next
itself.
bigint in 2.0 is underestimated. I would rather over-invest in that guide
than under-invest. Consider shipping 2.0 with bigint behind an opt-in for one
minor, then flipping — the one place a temporary flag genuinely earns its keep,
because the failure mode is a TypeError in production rather than a subtly
wrong value.
The plan outlives its usefulness. It assumes maintainer time that may not
materialise. Everything before 1.0 is additive, so the honest fallback is to
ship 0.6 and stop: promises, types, AbortSignal, deprecation warnings and
accessors are worthwhile on their own, and leave the project better even if no
major ever follows.