Skip to main content

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 /next track below. Track 2 shipped in full.

Two tracks, one package, no new npm name.

  1. 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. Dropped.
  2. 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.x minor, 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.

releaseshipped asthemeblast radiusmigration mechanism
0.60.6.0preparation, all additivenone
1.00.7.0errors are Errors ✅error-handling paths onlycodemod + forward-compatible shim
2.00.14.0the type system ✅every value readaccessors + lint + compat wrapper
3.00.13.0lifecycle and cancellation ✅connection setup/teardowncodemod + deprecations
4.0ESM, /next defaultimportscodemod

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 /next preview — never shipped, and the track is dropped.


1.0 — errors are Errors

Shipped as 0.7.0. Under semver a 0.x minor is already the breaking bump, so the content below did not need a 1.0 to land, and 1.0.0 is 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 npx when the consuming project has no jscodeshift of its own.

Closes #39, #178, #207, #208. Absorbs #213.

beforeafter
err is the message body arrayerr is a DBusError
err is [] for an empty bodyerr.message is the D-Bus error name
missing interface → (null, undefined)rejects with UnknownInterfaceError
connection dies → pending callbacks dropped silentlyall 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.x minor is already the breaking bump, so this landed without needing a 2.0.0 tag. plainValues and returnBigInt default to true; variants follows plainValues, so a v reads as its value. Every old shape is still an option, and withClassicTypes still restores all three at once — the classic run 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.js already read 64-bit exactly; it still lost a variant's signature, and delivered Variant('u', 9) to the next hop as i. Only the wrap run 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 at v exactly as a value inside a{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-Bus1.x2.0
v[parsedTree, [value]]the value; Variant when explicitly requested
a{sv}, a{ss}array of pairsplain object
x, tlossy number, or Long.js under a flagbigint
ayBufferBuffer — 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:

  1. The 0.6 accessors. Code using variantValue()/toPlain() needs no change. This is why 0.6 matters.

  2. 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.0

    Being 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.

  3. dbus-native/compat for code that cannot be migrated yet:

    import { withClassicTypes } from 'dbus-native/compat';
    const bus = withClassicTypes(sessionBus()); // 1.x shapes, on 2.0

    Shipped 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 plainValues discards 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/t come back as a rounded number again. 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.

Closes #20, #137.

  • Symbol.asyncDispose on connections, subscriptions and name registrations.

  • connection.end()await bus.close(), which flushes pending writes and fails in-flight calls cleanly instead of throwing ERR_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: 0 opts 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 a TimeoutError for a message that did exactly what it was told, so those get none — and since there is no reply to wait for, invoke now 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 /next half 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 instanceof across the boundary. So ESM-only buys nothing, while costing require() for every consumer on Node < 22.12 and anyone whose bundler does not implement require(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-native resolves to the modern API; the classic surface moves to dbus-native/classic and stays supported for a defined period.
  • ESM-only with a real exports map. Not dual: a dual package means two copies of Variant, and instanceof failing across them is a genuine hazard for a library whose whole point is value wrapping.
  • Node floor rises to whatever /next needs.

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 ./next in the exports map 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 fitshipped asbroke anything?
await usingbus.close(), bus.watch(), bus.ownName()no
proxiesbus.proxy(), beside getService()no
async-iterable signalsproxy.$signal(), bounded; $watch() for eventsno
declarative service defsdefineInterface(), compiles to the old arraysno

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:

  1. docs/deprecations.md with DBUS_DEP0001… anchors, and a deprecate(code, message) helper that warns once per code.
  2. The 0.6 forward-compatible accessorsvariantValue, toPlain, error properties. Small, and every later migration leans on them.
  3. 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.
  4. npx dbus-native lint for 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.
  5. A migration guide per majordocs/migrating-to-1.md and 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:

packageweeklyshare
@homebridge/dbus-native40,82761%
dbus-next19,273 (dormant since 2022)28%
dbus-native7,37011%

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.

/next half-finished. The failure mode of #251, and of dbus-next itself. 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.

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.