Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,17 @@ Append-only. Each decision has a stable never-reused `D-###` id, what was decide
**Obligations**: none blocking. One optional follow-up: remove the unused `_config` parameter from `createServer` in a change scoped to that refactor.
**From**: D-021's dependency review, which measured the bump as viable, recorded the `biome migrate` landmine, and deferred the work to its own PR.
**Folds into**: package.json, bun.lock, biome.json, test/lint-gate.test.ts, src/registry/index.ts, src/engine/index.ts, src/control-plane/index.ts, AGENTS.md (working notes), plus import ordering across 17 files and the 14 optional-chain sites

### D-024: Drop `aedes-server-factory`; the tcp listener is bare node:net
**Date**: 2026-08-01
**What**: Remove the `aedes-server-factory` dependency (`^0.2.1`) and replace its one call site with `net.createServer((socket) => aedes.handle(socket))` in `src/broker/index.ts`. The ws listener was already hand-built (that package's ws path never worked under Bun; see the note in `src/broker/index.ts`), so after this `broker/` drives aedes with no intermediary on either transport.
**Why**: The package has been dead since 2021-06-28, hard-depends on `ws@7` (the exact package whose Bun stand-in forced the hand-built ws listener in the first place) and `aedes-protocol-decoder`, and offbook used it for a single line. D-021 already identified it as "not a maintained path to 1.x"; removing it now is the one aedes-adjacent change that does **not** move the floor under R-006/R-007, because the spike surface (aedes core at 0.51.x + the hand-built ws listener) is untouched.
**Measured (2026-08-01, published tarballs + live probes)**:
- **The replacement is behaviorally equivalent for offbook's use.** Read from `aedes-server-factory@0.2.1`'s source: with no options its path is `net.createServer((conn) => bindConnection(...))`, and `bindConnection` without `trustProxy` is `req.connDetails = extractSocketDetails(conn); aedes.handle(conn, req)`. Nothing in the repo reads `connDetails` (repo-wide grep, exit 1), so the only delta is an unread metadata field.
- Dropping it removes `aedes-server-factory`, `aedes-protocol-decoder` and `ws@7.5.11` from the lockfile. `ws@8` remains via `mqtt` (dev dependency) and the Stryker runner; neither is a runtime path of `broker/`.
- **Recorded here for D-021 obligation (1): aedes 1.1.1 runs under Bun.** A 10-check probe driving it exactly the way `src/broker/index.ts` drives 0.51 — named-class construction plus `await listen()`, `preConnect`, `handle()` on a raw socket, the `on("publish")`/`on("subscribe")` shapes, `publish(packet, cb)`, `createRetainedStream("#")` consumed as a Readable (aedes-persistence 10 wraps its async generator in `Readable.from`), `close(cb)` — exits 0. The migration delta is three changes of roughly ten lines total: the named import (the 1.x default export throws a migration error), `await aedes.listen()` inside `start()` (persistence setup became async and moved out of the constructor), and this entry's node:net listener. The deferral itself **stands**: 1.x changes runtime defaults (a new `drainTimeout: 60000`, a `maxTopicLevels` clamp, keepalive limits) and R-006/R-007 measure ws fidelity against the broker's defaults, so the spikes still run first, on 0.51.x, and the bump lands after them with an R-033 rehearsal re-run.
**Mitigations / notes**: The Biome `noRestrictedImports` entry for `aedes-server-factory` and the `test/transport-isolation.test.ts` regex both keep listing the package deliberately — they now guard against reintroduction, and `test/lint-gate.test.ts` continues to assert the Biome entry unchanged. A new test in `src/broker/fingerprint.test.ts` proves the replacement listener carries data both ways over tcp (a QoS-1 publish reaches `onInbound`, an `emit` reaches a tcp subscriber); the pre-existing tcp coverage stopped at the CONNECT handshake. Historical documents (`docs/plans/`, D-021's own text) keep naming the package and are not rewritten.
**Consequences for earlier entries**: narrows D-021 obligation (1) — the aedes 1.x migration no longer involves replacing this package, and the "aedes-server-factory is not a maintained path to 1.x" clause is discharged by removal. The 1.x bump itself remains deferred behind R-006/R-007.
**Obligations**: none.
**From**: the "matters of aedes" follow-up to D-021 (2026-08-01): source reads of `aedes-server-factory@0.2.1`, tarball reads of `aedes@1.1.1` and `aedes-persistence@10.3.1`, and the Bun probe described above.
**Folds into**: package.json, bun.lock, src/broker/index.ts, src/broker/fingerprint.test.ts
11 changes: 0 additions & 11 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"dependencies": {
"@asyncapi/parser": "^3.6.0",
"aedes": "^0.51.3",
"aedes-server-factory": "^0.2.1",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"hono": "^4.6.0",
Expand Down
28 changes: 28 additions & 0 deletions src/broker/fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
import { MqttClient, connectAsync as mqttConnectAsync } from "mqtt";
import tcpStreamBuilder from "mqtt/lib/connect/tcp";
import { loadConfig } from "#src/config/index.ts";
import type { InboundEvent } from "#src/model/index.ts";
import type { BrokerModule, FingerprintEvent } from "./index.ts";
import { createBroker, fingerprintLine } from "./index.ts";

Expand Down Expand Up @@ -118,6 +119,33 @@ test("a tcp CONNECT emits a fingerprint with no ws block", async () => {
await client.endAsync();
});

// The tcp listener is bare node:net (D-024, replacing `aedes-server-factory`);
// the CONNECT test above only proves the handshake, so this one proves data
// flows through it in both directions.
test("the tcp listener round-trips data: a tcp publish reaches onInbound, an emit reaches a tcp subscriber", async () => {
const inbound: InboundEvent[] = [];
broker.onInbound((e) => inbound.push(e));
const client = await connectAsync(`mqtt://localhost:${TCP}`, {
reconnectPeriod: 0,
clientId: "fp-tcp-rt",
});
const got = new Promise<string>((resolve) =>
client.on("message", (topic, payload) =>
resolve(`${topic} ${payload.toString()}`),
),
);
await client.subscribeAsync("state/tcp-rt", { qos: 1 });
await client.publishAsync("command/tcp-rt", JSON.stringify({ n: 7 }), {
qos: 1,
});
await broker.emit({ topic: "state/tcp-rt", payload: { ok: true }, qos: 1 });
expect(await got).toBe('state/tcp-rt {"ok":true}');
const seen = inbound.find((e) => e.message.topic === "command/tcp-rt");
expect(seen?.message.payload).toEqual({ n: 7 });
expect(seen?.message.qos).toBe(1);
await client.endAsync();
});

test("subscribe observations dedupe per clientId·topic·qos; publish per clientId·qos·retain", async () => {
const client = await connectAsync(`ws://localhost:${WS}`, {
forceNativeWebSocket: true,
Expand Down
10 changes: 8 additions & 2 deletions src/broker/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import net from "node:net";
import { Duplex } from "node:stream";
import Aedes from "aedes";
import { createServer } from "aedes-server-factory";
import type { ServerWebSocket } from "bun";
import type {
Config,
Expand Down Expand Up @@ -280,7 +280,13 @@ export function createBroker(config: Config): BrokerModule {
},
}) as AedesWithPersistence;
const wsServer = createWsListener(aedes, wsFacts);
const tcpServer = createServer(aedes);
// Bare node:net on purpose — `aedes-server-factory`'s no-options path
// reduced to exactly this plus a `req.connDetails` nothing here reads, and
// the package is unmaintained with a hard ws@7 dependency (the same package
// whose Bun stand-in is why the ws listener above is hand-built). D-024.
const tcpServer = net.createServer((socket) => {
aedes.handle(socket);
});
let seq = 0;
const inbound: Array<(e: InboundEvent) => void> = [];
const subs: Array<(s: { topic: string; clientId: string }) => void> = [];
Expand Down