Skip to content

Commit 6f894e1

Browse files
committed
feat: update package versions and enhance topic pattern matching functionality
1 parent 023bd25 commit 6f894e1

11 files changed

Lines changed: 277 additions & 21 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
],
1111
"scripts": {
1212
"build": "bun run --filter './packages/*' build",
13-
"test": "bun test packages/core/src/app.test.ts packages/aedes/src/aedes-adapter.test.ts",
13+
"test": "bun test packages/core/src/matcher.test.ts packages/core/src/app.test.ts packages/aedes/src/aedes-adapter.test.ts",
1414
"typecheck": "tsc -p tsconfig.json --noEmit",
1515
"prepublish:check": "bun run test && bun run typecheck && bun run build",
1616
"publish:dry-run": "node scripts/publish.mjs --dry-run",

packages/aedes/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mqttkit/aedes",
3-
"version": "0.0.0",
3+
"version": "0.1.0",
44
"type": "module",
55
"description": "Aedes TCP and MQTT-over-WebSocket adapter for mqttkit.",
66
"license": "MIT",
@@ -40,7 +40,7 @@
4040
"build": "tsup src/index.ts --format esm --dts --clean"
4141
},
4242
"dependencies": {
43-
"@mqttkit/core": "0.0.0",
43+
"@mqttkit/core": "^0.1.0",
4444
"websocket-stream": "^5.5.2"
4545
},
4646
"devDependencies": {

packages/asyncapi/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mqttkit/asyncapi",
3-
"version": "0.0.0",
3+
"version": "0.1.0",
44
"type": "module",
55
"description": "AsyncAPI 3.0 documentation generator and HTTP plugin for mqttkit.",
66
"license": "MIT",
@@ -41,7 +41,7 @@
4141
"build": "tsup src/index.ts --format esm --dts --clean"
4242
},
4343
"dependencies": {
44-
"@mqttkit/core": "0.0.0"
44+
"@mqttkit/core": "^0.1.0"
4545
},
4646
"devDependencies": {
4747
"tsup": "^8.5.1",

packages/asyncapi/src/builder.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function buildFromRoutes(
1616
const channelId = toChannelId(route.pattern)
1717
const address = toAsyncApiAddress(route.pattern)
1818
const params = extractParams(route.pattern)
19+
const hasCatchAll = /(^|\/)\*(?=\/|$)/.test(route.pattern)
1920
const meta = (route.meta ?? {}) as RouteDocMeta
2021
const schema = route.config.schema
2122
const messageId = meta.message?.name ?? 'payload'
@@ -27,12 +28,15 @@ export function buildFromRoutes(
2728
examples: meta.examples?.map((payload) => ({ payload })),
2829
})
2930

31+
const parameters: Record<string, { description: string }> = Object.fromEntries(
32+
params.map((name) => [name, { description: `Path parameter :${name}` }]),
33+
)
34+
if (hasCatchAll) parameters.rest = { description: 'Catch-all remaining path segments' }
35+
3036
channels[channelId] = pruneEmpty({
3137
address,
3238
description: meta.description,
33-
parameters: params.length
34-
? Object.fromEntries(params.map((name) => [name, { description: `Path parameter :${name}` }]))
35-
: undefined,
39+
parameters: Object.keys(parameters).length ? parameters : undefined,
3640
messages: { [messageId]: message },
3741
tags: meta.tags?.map((name) => ({ name })),
3842
})
@@ -80,15 +84,14 @@ function toChannelId(pattern: string): string {
8084
.filter(Boolean)
8185
.map((seg) => {
8286
if (seg.startsWith(':')) return `{${seg.slice(1)}}`
83-
if (seg === '+') return 'plus'
84-
if (seg === '#') return 'all'
87+
if (seg === '*') return '{rest}'
8588
return seg
8689
})
8790
.join('.')
8891
}
8992

9093
function toAsyncApiAddress(pattern: string): string {
91-
return pattern.replace(/:(\w+)/g, '{$1}')
94+
return pattern.replace(/:(\w+)/g, '{$1}').replace(/(^|\/)\*(?=\/|$)/g, '$1{rest}')
9295
}
9396

9497
function extractParams(pattern: string): string[] {

packages/core/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,35 @@ await app.listen()
4747
- `app.on(eventName, handler)` listens to broker lifecycle events.
4848
- `app.publish(topic, payload, options)` publishes from the server side through the configured broker.
4949

50+
## Topic Pattern Syntax
51+
52+
Patterns use Elysia-style segments rather than MQTT wildcards:
53+
54+
| Segment | Meaning |
55+
| ------- | ----------------------------------------------------------------------------- |
56+
| `foo` | Literal segment. Must match exactly. |
57+
| `:name` | Named parameter. Matches one segment and exposes it as `ctx.params.name`. |
58+
| `*` | Catch-all. Must be the last segment. Joined remainder lands in `ctx.params['*']`. |
59+
60+
```ts
61+
router()
62+
.topic('devices/:uid/events') // ctx.params.uid
63+
.topic('files/*') // ctx.params['*'] = 'a/b/c'
64+
.topic('users/:uid/inbox/*') // both
65+
```
66+
67+
MQTT wildcards `+` and `#` are **not** part of the pattern syntax — they would
68+
be treated as literal characters. They are only recognised on the subscribing
69+
client side: when a client subscribes to a topic containing `+` / `#`,
70+
mqttkit matches it against route patterns without binding any `:name` parameter.
71+
Subscription policies that depend on `params.uid` will therefore deny wildcard
72+
subscriptions naturally.
73+
74+
```ts
75+
// Pattern: 'devices/:uid/events'
76+
// Client subscribes 'devices/+/events' → matched, params = {}
77+
// Client subscribes 'devices/#' → matched, params = {}
78+
// Client subscribes 'devices/abc/x' → no match
79+
```
80+
5081
See the repository README for full examples.

packages/core/README.zh-CN.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,33 @@ await app.listen()
4747
- `app.on(eventName, handler)` 监听 broker lifecycle events。
4848
- `app.publish(topic, payload, options)` 通过已配置 broker 从服务端发布消息。
4949

50+
## Topic Pattern 语法
51+
52+
Pattern 使用 Elysia 风格的段,而非 MQTT 通配符:
53+
54+
|| 含义 |
55+
| -------- | ---------------------------------------------------------------------- |
56+
| `foo` | 字面段,需要精确相等。 |
57+
| `:name` | 命名参数,匹配单段并写入 `ctx.params.name`|
58+
| `*` | catch-all,必须放在最后,剩余段拼接后写入 `ctx.params['*']`|
59+
60+
```ts
61+
router()
62+
.topic('devices/:uid/events') // ctx.params.uid
63+
.topic('files/*') // ctx.params['*'] = 'a/b/c'
64+
.topic('users/:uid/inbox/*') // 两者并用
65+
```
66+
67+
MQTT 的 `+` / `#` ****属于 pattern 语法 —— 写在 pattern 里只会被当成字面字符。
68+
它们只在客户端订阅一侧被识别:客户端用包含 `+` / `#` 的 topic 订阅时,mqttkit
69+
会和路由 pattern 做"通配符匹配通配符"的判断,**不会**把任何值绑定到 `:name`
70+
所以依赖 `params.uid` 的 subscribe policy 在面对通配订阅时会自然失败。
71+
72+
```ts
73+
// Pattern: 'devices/:uid/events'
74+
// 客户端订阅 'devices/+/events' → 命中,params = {}
75+
// 客户端订阅 'devices/#' → 命中,params = {}
76+
// 客户端订阅 'devices/abc/x' → 未命中
77+
```
78+
5079
完整示例见仓库 README。

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mqttkit/core",
3-
"version": "0.0.0",
3+
"version": "0.1.0",
44
"type": "module",
55
"description": "Elysia-like MQTT application framework for TypeScript.",
66
"license": "MIT",

packages/core/src/app.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,66 @@ describe('MqttApp + router().topic()', () => {
122122
params: { uid: 'alice' },
123123
})
124124
})
125+
126+
test('canSubscribe 接受 MQTT 客户端的 + / # 通配符', async () => {
127+
const broker = new TestBroker()
128+
const app = new MqttApp()
129+
.use({ setup: (app) => { app.broker(broker) } })
130+
.use(
131+
router()
132+
.topic('server/:uid/commands')
133+
.topic('private/:uid', {
134+
subscribe: ({ params, clientId }) => params.uid === clientId,
135+
}),
136+
)
137+
138+
await app.listen()
139+
140+
// + 匹配单层但不绑定 uid → 通配订阅放行(默认 subscribe: true)
141+
await expect(app.canSubscribe({ topic: 'server/+/commands', clientId: 'client-a' })).resolves.toEqual({
142+
allowed: true,
143+
params: {},
144+
})
145+
146+
// 自定义 policy 依赖 params.uid,使用 + 时 uid 缺失 → 拒绝
147+
await expect(app.canSubscribe({ topic: 'private/+', clientId: 'bob' })).resolves.toEqual({
148+
allowed: false,
149+
params: {},
150+
})
151+
152+
// # 吃掉剩余段
153+
await expect(app.canSubscribe({ topic: 'server/#', clientId: 'client-a' })).resolves.toEqual({
154+
allowed: true,
155+
params: {},
156+
})
157+
})
158+
159+
test('dispatch 走严格 match,pattern 里的 + / # 不再是通配符', async () => {
160+
const broker = new TestBroker()
161+
let called = false
162+
const app = new MqttApp()
163+
.use({ setup: (app) => { app.broker(broker) } })
164+
// pattern 里出现 '+' 现在是字面值,不再是 MQTT 单层通配符
165+
.use(router().topic('devices/+/events', { onMessage() { called = true } }))
166+
167+
await app.listen()
168+
169+
// 字面 '+' 不会匹配具体设备 id
170+
const missed = await broker.dispatch({
171+
topic: 'devices/abc/events',
172+
payload: Buffer.from('x'),
173+
clientId: 'client-a',
174+
})
175+
expect(missed).toBe(false)
176+
expect(called).toBe(false)
177+
178+
// 完全字面相等才会派发
179+
const hit = await broker.dispatch({
180+
topic: 'devices/+/events',
181+
payload: Buffer.from('x'),
182+
clientId: 'client-a',
183+
})
184+
expect(hit).toBe(true)
185+
expect(called).toBe(true)
186+
})
125187
})

packages/core/src/dispatcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class Dispatcher<TState extends MqttAppState = MqttAppState> {
4242

4343
async canSubscribe(input: SubscribeCheckInput<TState['principal']>): Promise<SubscribeCheckResult> {
4444
for (const route of this.options.routes) {
45-
const params = route.compiled.match(input.topic)
45+
const params = route.compiled.matchSubscription(input.topic)
4646
if (!params) continue
4747

4848
const allowed = await evaluatePolicy(route.subscribe, {

packages/core/src/matcher.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { compileTopicPattern } from './matcher.js'
3+
4+
describe('compileTopicPattern.match (publish/dispatch path)', () => {
5+
test('literal pattern 必须完全相等', () => {
6+
const c = compileTopicPattern('devices/online')
7+
expect(c.match('devices/online')).toEqual({})
8+
expect(c.match('devices/offline')).toBeNull()
9+
expect(c.match('devices/online/extra')).toBeNull()
10+
})
11+
12+
test(':name 命名参数会提取值', () => {
13+
const c = compileTopicPattern('devices/:uid/events')
14+
expect(c.match('devices/abc/events')).toEqual({ uid: 'abc' })
15+
expect(c.match('devices/abc/other')).toBeNull()
16+
expect(c.match('devices/abc')).toBeNull()
17+
})
18+
19+
test('* catch-all 会把剩余部分收集到 params["*"]', () => {
20+
const c = compileTopicPattern('files/*')
21+
expect(c.match('files/a')).toEqual({ '*': 'a' })
22+
expect(c.match('files/a/b/c')).toEqual({ '*': 'a/b/c' })
23+
expect(c.match('files')).toEqual({ '*': '' })
24+
expect(c.match('other/a')).toBeNull()
25+
})
26+
27+
test(':name 与 * 可以混用', () => {
28+
const c = compileTopicPattern('users/:uid/*')
29+
expect(c.match('users/alice/profile/avatar')).toEqual({ uid: 'alice', '*': 'profile/avatar' })
30+
expect(c.match('users/alice')).toEqual({ uid: 'alice', '*': '' })
31+
})
32+
33+
test('* 出现在非末尾抛错', () => {
34+
expect(() => compileTopicPattern('a/*/b')).toThrow(/Catch-all wildcard/)
35+
})
36+
37+
test('空 :param 名抛错', () => {
38+
expect(() => compileTopicPattern('a/:')).toThrow(/parameter name cannot be empty/)
39+
})
40+
41+
test('MQTT 通配符 + / # 在 pattern 里被当成 literal,不再生效', () => {
42+
const c = compileTopicPattern('devices/+/events')
43+
// '+' 不再是单层通配符,只能字面匹配
44+
expect(c.match('devices/abc/events')).toBeNull()
45+
expect(c.match('devices/+/events')).toEqual({})
46+
})
47+
})
48+
49+
describe('compileTopicPattern.matchSubscription (subscribe path)', () => {
50+
test('精确订阅与 publish-path 行为一致', () => {
51+
const c = compileTopicPattern('devices/:uid/events')
52+
expect(c.matchSubscription('devices/abc/events')).toEqual({ uid: 'abc' })
53+
expect(c.matchSubscription('devices/abc/other')).toBeNull()
54+
})
55+
56+
test('订阅中的 + 匹配单层但不绑定 param', () => {
57+
const c = compileTopicPattern('devices/:uid/events')
58+
expect(c.matchSubscription('devices/+/events')).toEqual({})
59+
expect(c.matchSubscription('devices/+/other')).toBeNull()
60+
})
61+
62+
test('订阅中的 # 吃掉剩余所有段', () => {
63+
const c = compileTopicPattern('devices/:uid/events')
64+
expect(c.matchSubscription('devices/#')).toEqual({})
65+
expect(c.matchSubscription('#')).toEqual({})
66+
})
67+
68+
test('订阅与 catch-all pattern 互相兼容', () => {
69+
const c = compileTopicPattern('files/*')
70+
expect(c.matchSubscription('files/#')).toEqual({ '*': '' })
71+
expect(c.matchSubscription('files/+/a')).toEqual({ '*': '+/a' })
72+
expect(c.matchSubscription('files/a/b')).toEqual({ '*': 'a/b' })
73+
})
74+
75+
test('+ 能匹配命名参数所在的段但不会泄露值给 param', () => {
76+
const c = compileTopicPattern('users/:uid/notifications')
77+
const params = c.matchSubscription('users/+/notifications')
78+
expect(params).toEqual({})
79+
expect(params).not.toHaveProperty('uid')
80+
})
81+
})

0 commit comments

Comments
 (0)