Skip to content

Commit 9e2a98d

Browse files
committed
feat(aedes): upgrade to aedes 2.x and implement MQTT 5 rejections
- Target aedes 2.x, which is ESM-only and requires Node >= 20. - Change schema validation to reject with MQTT 5 reason codes instead of dropping connections. - Move `@mqttkit/core` to peerDependencies for consistent usage across adapters. - Add example for schema rejection handling in `examples/aedes-schema-reject`. - Update tooling to route prerelease versions to the `next` dist-tag. feat(asyncapi): update to version 0.2.2 with peerDependencies adjustment - Move `@mqttkit/core` to peerDependencies for consistency with other adapters. chore: update package.json files across examples and packages for aedes 2.x compatibility - Change aedes dependency to 2.0.0-beta.1 in relevant package.json files. - Update README files to reflect changes and requirements for aedes 2.x. - Adjust tests to ignore internal broker traffic in aedes 2.x.
1 parent ba2c5fe commit 9e2a98d

14 files changed

Lines changed: 313 additions & 68 deletions

File tree

bun.lock

Lines changed: 30 additions & 49 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/changelog.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,47 @@ together stay readable.
1212

1313
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
1414

15+
## 2026-07-27 — aedes 2.x / MQTT 5 rejections
16+
17+
`@mqttkit/aedes@0.3.0-beta.0` (prerelease, `next` dist-tag —
18+
`npm i @mqttkit/aedes@next aedes@2`) and `@mqttkit/asyncapi@0.2.2`.
19+
20+
### Changed — `@mqttkit/aedes` 0.3.0-beta.0
21+
22+
- **Targets aedes 2.x** (`peerDependencies: aedes >=2.0.0-beta.1 <3`). Breaking:
23+
aedes 2.0 is ESM-only and needs Node >= 20. The adapter now imports the
24+
`Aedes` class (the `createBroker` named export was removed) and awaits the new
25+
async `broker.listen()` inside `start()` — aedes 2.x moved persistence setup
26+
and heartbeat there. Bring your own `aedes@2`.
27+
- **Schema failures now reject with an MQTT 5 reason code instead of dropping
28+
the connection.** Inbound dispatch runs inside `authorizePublish` (before the
29+
broker acks/forwards), so a failed `validate: 'inbound'` route rejects the
30+
publish before any subscriber sees it. On aedes 2.x an MQTT 5 publisher gets a
31+
`0x87` (Not authorized) PUBACK/PUBREC and stays connected; v3/v4 clients have
32+
no reason-code channel and are still disconnected.
33+
- **`@mqttkit/core` moved to `peerDependencies`** (was a regular dependency).
34+
The adapter plugs into the user's `MqttApp`, so core must be the single shared
35+
copy — otherwise a version split yields two `@mqttkit/core` instances and
36+
`app.use(aedes(...))` fails to type-check. Now consistent with `@mqttkit/zod`
37+
and `@mqttkit/typebox`. Install core yourself (the README already shows this).
38+
39+
### Changed — `@mqttkit/asyncapi` 0.2.2
40+
41+
- **`@mqttkit/core` moved to `peerDependencies`** (was a regular dependency),
42+
the same fix as `@mqttkit/aedes` above. As a plugin over the user's app it
43+
must share the single core copy. Now consistent with all other adapters.
44+
45+
### Added — examples
46+
47+
- `examples/aedes-schema-reject` — end-to-end demo: an MQTT 5 client publishes a
48+
valid then an invalid payload; the invalid one is rejected with a reason code
49+
while the connection stays up and the handler never runs.
50+
51+
### Tooling
52+
53+
- `scripts/publish.mjs` routes prerelease versions (any version containing a `-`)
54+
to the npm `next` dist-tag so betas never land on `latest`.
55+
1556
## 2026-06-23 — quality pass
1657

1758
`@mqttkit/core@0.2.2`, `@mqttkit/asyncapi@0.2.1`, `@mqttkit/zod@0.1.1`.

docs/zh/changelog.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,43 @@ aside: false
1010

1111
格式大致基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/)
1212

13+
## 2026-07-27 — aedes 2.x / MQTT 5 拒绝回执
14+
15+
`@mqttkit/aedes@0.3.0-beta.0`(预发布,`next` dist-tag ——
16+
`npm i @mqttkit/aedes@next aedes@2`)和 `@mqttkit/asyncapi@0.2.2`
17+
18+
### 变更 — `@mqttkit/aedes` 0.3.0-beta.0
19+
20+
- **面向 aedes 2.x**(`peerDependencies: aedes >=2.0.0-beta.1 <3`)。破坏性变更:
21+
aedes 2.0 纯 ESM 且要求 Node >= 20。适配器改为 import `Aedes` 类(`createBroker`
22+
具名导出已被移除),并在 `start()``await` 新的异步 `broker.listen()`——aedes
23+
2.x 把 persistence 初始化和心跳搬到了那里。需自行安装 `aedes@2`
24+
- **schema 校验失败改为回 MQTT 5 reason code,而不是断开连接。** 入站派发在
25+
`authorizePublish` 里执行(在 broker 回 ack / 转发之前),所以配了
26+
`validate: 'inbound'` 的路由会在任何订阅者看到之前就拒绝坏 payload。aedes 2.x 下
27+
MQTT 5 发布端会收到 `0x87`(Not authorized)的 PUBACK/PUBREC 且连接不断;v3/v4
28+
客户端没有 reason-code 通道,仍以断开处理。
29+
- **`@mqttkit/core``dependencies` 挪到 `peerDependencies`** 适配器是插到
30+
用户的 `MqttApp` 上的,core 必须是同一份共享拷贝——否则版本分叉会出现两份
31+
`@mqttkit/core` 实例,`app.use(aedes(...))` 类型不通过。现在与 `@mqttkit/zod`
32+
`@mqttkit/typebox` 一致。core 需你自行安装(README 已有说明)。
33+
34+
### 变更 — `@mqttkit/asyncapi` 0.2.2
35+
36+
- **`@mqttkit/core``dependencies` 挪到 `peerDependencies`**,和上面 `@mqttkit/aedes`
37+
同样的修复。作为插到用户 app 上的插件,它必须共用同一份 core。现在与其余所有
38+
适配器一致。
39+
40+
### 新增 — 示例
41+
42+
- `examples/aedes-schema-reject` —— 端到端演示:MQTT 5 客户端先发合法、再发非法
43+
payload;非法的那条被回 reason code 拒绝,连接保持,handler 不执行。
44+
45+
### 工具
46+
47+
- `scripts/publish.mjs` 会把预发布版本(版本号含 `-`)发到 npm `next` dist-tag,
48+
避免 beta 落到 `latest`
49+
1350
## 2026-06-23 — 质量优化
1451

1552
`@mqttkit/core@0.2.2``@mqttkit/asyncapi@0.2.1``@mqttkit/zod@0.1.1`
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@mqttkit/example-aedes-schema-reject",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "bun run src/index.ts",
7+
"typecheck": "tsc --noEmit"
8+
},
9+
"dependencies": {
10+
"@mqttkit/aedes": "workspace:*",
11+
"@mqttkit/core": "workspace:*"
12+
},
13+
"devDependencies": {
14+
"aedes": "2.0.0-beta.1",
15+
"mqtt": "^5.15.1"
16+
}
17+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Schema validation as an *inbound firewall* — with an MQTT 5 reason code
3+
* instead of a dropped connection.
4+
*
5+
* The important detail is WHERE validation runs. The `@mqttkit/aedes` adapter
6+
* dispatches every inbound PUBLISH inside aedes' `authorizePublish` hook, i.e.
7+
* BEFORE the broker acknowledges or forwards the message. So a schema failure
8+
* genuinely rejects the publish: the handler never runs and no subscriber ever
9+
* sees the bad payload.
10+
*
11+
* (If instead you run validation on a `broker.on('publish')` listener — a
12+
* common mistake in hand-rolled broker wrappers — it fires AFTER the PUBACK and
13+
* AFTER the message was forwarded, so it can only observe, never block.)
14+
*
15+
* On aedes 2.x, when the publisher is an MQTT 5 client, a rejected QoS>0 publish
16+
* comes back as a PUBACK/PUBREC carrying reason code 0x87 (Not authorized) — the
17+
* connection stays up, unlike the v3/v4 behaviour which drops the socket.
18+
*
19+
* Run: bun run src/index.ts
20+
*/
21+
import { AedesBrokerAdapter } from '@mqttkit/aedes'
22+
import { MqttApp, router, SchemaValidationError, type StandardSchemaV1 } from '@mqttkit/core'
23+
import mqtt from 'mqtt'
24+
25+
// ---- A tiny Standard Schema: { temperature: number } ----
26+
type Reading = { temperature: number }
27+
28+
const readingSchema: StandardSchemaV1<unknown, Reading> = {
29+
'~standard': {
30+
version: 1,
31+
vendor: 'mqttkit-example',
32+
validate(value) {
33+
if (typeof value !== 'object' || value === null) {
34+
return { issues: [{ message: 'expected object', path: [] }] }
35+
}
36+
const temperature = (value as Record<string, unknown>).temperature
37+
if (typeof temperature !== 'number') {
38+
return { issues: [{ message: 'expected number', path: ['temperature'] }] }
39+
}
40+
return { value: { temperature } }
41+
},
42+
types: { input: undefined as unknown, output: undefined as unknown as Reading },
43+
},
44+
}
45+
46+
const handled: Reading[] = []
47+
48+
const adapter = new AedesBrokerAdapter<{ uid: string }>({
49+
tcp: { port: 0 },
50+
ws: false,
51+
authenticate: ({ clientId }) => ({ uid: clientId }),
52+
})
53+
54+
const app = new MqttApp<{ principal?: { uid: string } }>()
55+
.use({ setup: (a) => { a.broker(adapter) } })
56+
// Central place to observe rejections. phase === 'validation' means the schema
57+
// gate stopped the message before the handler.
58+
.onError((e) => {
59+
if (e.phase === 'validation') {
60+
const detail = e.error instanceof SchemaValidationError ? e.error.message : String(e.error)
61+
console.log(`❌ [server] rejected publish on "${e.topic}" — ${detail}`)
62+
}
63+
})
64+
.use(
65+
router<{ principal?: { uid: string } }>().topic('devices/:uid/reading', {
66+
publish: true,
67+
schema: readingSchema,
68+
validate: 'inbound',
69+
async onMessage(ctx) {
70+
// Only ever reached for payloads that passed the schema.
71+
handled.push(ctx.body as Reading)
72+
console.log('✅ [server] handler ran, body =', ctx.body)
73+
},
74+
}),
75+
)
76+
77+
await app.listen()
78+
const port = adapter.getTcpAddress()?.port
79+
console.log(`broker listening on mqtt://127.0.0.1:${port}\n`)
80+
81+
// Connect as an MQTT 5 client so a rejection can carry a reason code.
82+
const client = await mqtt.connectAsync(`mqtt://127.0.0.1:${port}`, {
83+
protocolVersion: 5,
84+
clientId: 'sensor-1',
85+
})
86+
87+
async function tryPublish(label: string, payload: unknown) {
88+
try {
89+
const ack = (await client.publishAsync(
90+
'devices/sensor-1/reading',
91+
JSON.stringify(payload),
92+
{ qos: 1 },
93+
)) as { reasonCode?: number } | undefined
94+
console.log(`[client] ${label}: PUBACK reasonCode = ${ack?.reasonCode ?? 0}`)
95+
} catch (err) {
96+
console.log(`[client] ${label}: rejected — ${(err as Error).message}`)
97+
}
98+
}
99+
100+
console.log('--- valid payload ---')
101+
await tryPublish('valid ', { temperature: 21.5 })
102+
103+
console.log('\n--- invalid payload (temperature is a string) ---')
104+
await tryPublish('invalid', { temperature: 'hot' })
105+
106+
// The bad publish did NOT drop the connection — prove the client is still usable.
107+
await new Promise((r) => setTimeout(r, 50))
108+
console.log('\n[client] still connected after the rejected publish?', client.connected)
109+
console.log('[server] handler ran', handled.length, 'time(s) — only the valid payload got through')
110+
111+
await client.endAsync()
112+
await app.stop()

examples/asyncapi-elysia/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@mqttkit/aedes": "workspace:*",
1111
"@mqttkit/asyncapi": "workspace:*",
1212
"@mqttkit/core": "workspace:*",
13-
"aedes": "^0.51.3",
13+
"aedes": "2.0.0-beta.1",
1414
"elysia": "^1.4.29",
1515
"mqtt": "^5.15.1"
1616
}

packages/aedes/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ Full documentation: **<https://mqttkit.keyp.dev>**.
99
## Install
1010

1111
```bash
12-
bun add @mqttkit/core @mqttkit/aedes aedes
12+
bun add @mqttkit/core @mqttkit/aedes aedes@2
1313
```
1414

15+
> **aedes 2.x required.** This adapter targets aedes `>=2.0.0-beta.1` (ESM-only,
16+
> Node >= 20). aedes is a peer dependency, so you install it yourself. aedes 2.x
17+
> is the first line that speaks MQTT 5 on the wire — which unlocks reason-code
18+
> rejections (see below).
19+
1520
## Usage
1621

1722
```ts
@@ -46,6 +51,18 @@ await app.listen()
4651
- Forwards MQTT 5 publish properties (`responseTopic`, `correlationData`, `userProperties`, …) needed for `app.request()` RPC and tracing.
4752
- Aedes lifecycle events forwarded to `app.on(...)`; Aedes persistence options passed through.
4853

54+
## Schema validation is an inbound firewall
55+
56+
Every inbound PUBLISH is dispatched inside aedes' `authorizePublish` hook —
57+
i.e. **before** the broker acknowledges or forwards the message. A route with a
58+
`schema` and `validate: 'inbound'` therefore *rejects* bad payloads: the handler
59+
never runs and no subscriber sees them.
60+
61+
On aedes 2.x, a rejected QoS>0 publish from an **MQTT 5** client comes back as a
62+
PUBACK/PUBREC with reason code `0x87` (Not authorized) — the connection stays
63+
up. (v3/v4 clients have no reason-code channel, so the publish is dropped by
64+
disconnecting.) See `examples/aedes-schema-reject`.
65+
4966
## Docs
5067

5168
- [Getting Started](https://mqttkit.keyp.dev/getting-started) · [Aedes adapter](https://mqttkit.keyp.dev/aedes)

packages/aedes/README.zh-CN.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ mqttkit 的 Aedes adapter。把 `MqttApp` 接到 MQTT TCP 和 MQTT-over-WebSocke
99
## 安装
1010

1111
```bash
12-
bun add @mqttkit/core @mqttkit/aedes aedes
12+
bun add @mqttkit/core @mqttkit/aedes aedes@2
1313
```
1414

15+
> **需要 aedes 2.x。** 本适配器面向 aedes `>=2.0.0-beta.1`(纯 ESM,Node >= 20)。
16+
> aedes 是 peer dependency,需你自行安装。aedes 2.x 是第一个在网络层支持 MQTT 5
17+
> 的版本——由此才能用 reason code 拒绝(见下文)。
18+
1519
## 使用
1620

1721
```ts
@@ -46,6 +50,16 @@ await app.listen()
4650
- 透传 MQTT 5 publish properties(`responseTopic``correlationData``userProperties` 等),`app.request()` RPC 与 tracing 依赖这一点。
4751
- Aedes lifecycle events 转发到 `app.on(...)`;Aedes persistence 配置原样透传。
4852

53+
## Schema 校验是入站防火墙
54+
55+
每条入站 PUBLISH 都在 aedes 的 `authorizePublish` 钩子里派发——也就是在 broker
56+
**回 ACK / 转发之前**。所以配了 `schema` + `validate: 'inbound'` 的路由会真正
57+
*拒绝*坏 payload:handler 不会跑,订阅者也收不到。
58+
59+
在 aedes 2.x 下,来自 **MQTT 5** 客户端、被拒绝的 QoS>0 publish 会收到带 reason
60+
code `0x87`(Not authorized)的 PUBACK/PUBREC——连接不断开。(v3/v4 客户端没有
61+
reason-code 通道,只能靠断开连接来丢弃。)示例见 `examples/aedes-schema-reject`
62+
4963
## 文档
5064

5165
- [快速开始](https://mqttkit.keyp.dev/zh/getting-started) · [Aedes 适配器](https://mqttkit.keyp.dev/zh/aedes)

packages/aedes/package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mqttkit/aedes",
3-
"version": "0.2.0",
3+
"version": "0.3.0-beta.0",
44
"type": "module",
55
"description": "Aedes TCP and MQTT-over-WebSocket adapter for mqttkit.",
66
"license": "MIT",
@@ -36,22 +36,26 @@
3636
"publishConfig": {
3737
"access": "public"
3838
},
39+
"engines": {
40+
"node": ">=20"
41+
},
3942
"scripts": {
4043
"build": "tsup src/index.ts --format esm --dts --clean"
4144
},
4245
"dependencies": {
43-
"@mqttkit/core": "^0.2.0",
4446
"websocket-stream": "^5.5.2"
4547
},
4648
"devDependencies": {
49+
"@mqttkit/core": "workspace:*",
4750
"@types/ws": "^8.18.1",
48-
"aedes": "^0.51.3",
51+
"aedes": "2.0.0-beta.1",
4952
"mqtt": "^5.15.1",
5053
"ws": "^8.18.3",
5154
"tsup": "^8.5.1",
5255
"typescript": "^5.9.3"
5356
},
5457
"peerDependencies": {
55-
"aedes": "^0.51.3"
58+
"@mqttkit/core": "^0.2.0",
59+
"aedes": ">=2.0.0-beta.1 <3"
5660
}
5761
}

packages/aedes/src/aedes-adapter.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,10 @@ describe('@mqttkit/aedes', () => {
181181
;(adapter.broker as unknown as {
182182
publish: (packet: Record<string, unknown>, done: (err?: Error) => void) => void
183183
}).publish = (packet, done) => {
184-
captured.push(packet)
184+
// aedes 2.x publishes $SYS/<id>/birth (and periodic heartbeat) through
185+
// broker.publish during listen(); ignore broker-internal traffic so the
186+
// capture only sees packets mqttkit constructed.
187+
if (!String(packet.topic).startsWith('$SYS/')) captured.push(packet)
185188
done()
186189
}
187190

@@ -227,7 +230,10 @@ describe('@mqttkit/aedes', () => {
227230
;(adapter.broker as unknown as {
228231
publish: (packet: Record<string, unknown>, done: (err?: Error) => void) => void
229232
}).publish = (packet, done) => {
230-
captured.push(packet)
233+
// aedes 2.x publishes $SYS/<id>/birth (and periodic heartbeat) through
234+
// broker.publish during listen(); ignore broker-internal traffic so the
235+
// capture only sees packets mqttkit constructed.
236+
if (!String(packet.topic).startsWith('$SYS/')) captured.push(packet)
231237
done()
232238
}
233239

@@ -252,7 +258,10 @@ describe('@mqttkit/aedes', () => {
252258
;(adapter.broker as unknown as {
253259
publish: (packet: Record<string, unknown>, done: (err?: Error) => void) => void
254260
}).publish = (packet, done) => {
255-
captured.push(packet)
261+
// aedes 2.x publishes $SYS/<id>/birth (and periodic heartbeat) through
262+
// broker.publish during listen(); ignore broker-internal traffic so the
263+
// capture only sees packets mqttkit constructed.
264+
if (!String(packet.topic).startsWith('$SYS/')) captured.push(packet)
256265
done()
257266
}
258267

0 commit comments

Comments
 (0)