@venn-lang/mqtt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +243 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/actions/connect.ts +17 -0
- package/src/actions/expect.ts +17 -0
- package/src/actions/index.ts +13 -0
- package/src/actions/publish.ts +32 -0
- package/src/actions/subscribe.ts +17 -0
- package/src/clients/fake-client.ts +67 -0
- package/src/clients/index.ts +2 -0
- package/src/clients/real-client.ts +26 -0
- package/src/index.ts +12 -0
- package/src/matchers/index.ts +4 -0
- package/src/matchers/on-topic.ts +18 -0
- package/src/plugin.ts +22 -0
- package/src/port/index.ts +2 -0
- package/src/port/mqtt-client.port.ts +15 -0
- package/src/port/mqtt-client.types.ts +34 -0
- package/src/types/index.ts +3 -0
- package/src/types/message.ts +14 -0
- package/src/types/message.types.ts +7 -0
- package/src/types/type-defs.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinicius Borges
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @venn-lang/mqtt
|
|
2
|
+
|
|
3
|
+
> The `mqtt` namespace: connect to a broker, publish, subscribe, wait for a message on a topic.
|
|
4
|
+
|
|
5
|
+
Venn's grammar knows no verbs. `@venn-lang/mqtt` registers the `mqtt` namespace with the runtime, so
|
|
6
|
+
`mqtt.publish` resolves to an action and `mqtt.expect` hands back a typed message. The connection and
|
|
7
|
+
the subscriptions live behind the `MqttClient` port, not in a handle the flow carries around, so a
|
|
8
|
+
host swaps the whole transport by binding a different implementation.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Nothing to install yet. The package is unpublished (version `0.0.0`) and ships inside
|
|
13
|
+
`@venn-lang/stdlib`, which the `venn` CLI and the language server both load. A `.vn` file declares it:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
use "venn/mqtt"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
module demo.inventory
|
|
23
|
+
|
|
24
|
+
use "venn/mqtt"
|
|
25
|
+
use "venn/assert"
|
|
26
|
+
|
|
27
|
+
flow "Inventory" {
|
|
28
|
+
step "the broker relays the stock change" {
|
|
29
|
+
mqtt.connect "mqtt://broker.test:1883"
|
|
30
|
+
mqtt.subscribe "inventory/sku-42"
|
|
31
|
+
mqtt.publish "inventory/sku-42" { json: { delta: -1 }, qos: 1 }
|
|
32
|
+
|
|
33
|
+
const msg = mqtt.expect "inventory/sku-42"
|
|
34
|
+
expect msg topic "inventory/sku-42"
|
|
35
|
+
expect msg.payload.delta == -1
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
Everything below is exported from the package barrel.
|
|
43
|
+
|
|
44
|
+
| Export | What it is |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `mqttPlugin` (also the default export) | The `PluginDefinition`: namespace `mqtt`, `requires: ["net"]`. |
|
|
47
|
+
| `MqttClientPort` | `Port<MqttClient>`, id `venn.port.mqtt-client`, version 1, methods `connect`, `publish`, `subscribe`, `expect`. |
|
|
48
|
+
| `createFakeMqttClient({ seed })` | The double: a topic-to-queue map that records publishes and subscriptions. |
|
|
49
|
+
| `createRealMqttClient()` | The real client. Out of scope for this build: every method throws `VN8090`. |
|
|
50
|
+
| `messageSchema` | The Zod schema behind the nominal `Message` type the plugin registers. |
|
|
51
|
+
| `mqttTypeDefs` | What the plugin publishes to the checker, as `TypeSpec` data. |
|
|
52
|
+
|
|
53
|
+
Types: `MqttClient`, `FakeMqttClient`, `MqttPublishArgs`, `MqttMessage`.
|
|
54
|
+
|
|
55
|
+
## Verbs
|
|
56
|
+
|
|
57
|
+
| Verb | Shape | Result |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `mqtt.connect` | `mqtt.connect broker` | nothing |
|
|
60
|
+
| `mqtt.publish` | `mqtt.publish topic { json, qos, retain, will }` | nothing |
|
|
61
|
+
| `mqtt.subscribe` | `mqtt.subscribe topic` | nothing |
|
|
62
|
+
| `mqtt.expect` | `mqtt.expect topic` | `mqtt.Message` |
|
|
63
|
+
|
|
64
|
+
Each verb takes one positional argument: the broker URL for `connect`, the topic (wildcards and all)
|
|
65
|
+
for the other three. Only `publish` has an options map, and it carries `json` (the payload), `qos`,
|
|
66
|
+
`retain` and `will`.
|
|
67
|
+
|
|
68
|
+
`subscribe` hands nothing back: the subscription is the port's to remember, and `mqtt.expect` is how a
|
|
69
|
+
flow reaches what arrived.
|
|
70
|
+
|
|
71
|
+
## Matchers and types
|
|
72
|
+
|
|
73
|
+
`topic` is the one matcher: `expect msg topic "inventory/ack"` passes when the message arrived on that
|
|
74
|
+
topic.
|
|
75
|
+
|
|
76
|
+
The plugin publishes one type, `mqtt.Message`, with `topic`, `payload`, and optional `qos` and
|
|
77
|
+
`retain`. It is what `mqtt.expect` hands back, and what tells the checker that `msg` can carry the
|
|
78
|
+
`topic` matcher.
|
|
79
|
+
|
|
80
|
+
## Ports and conformance
|
|
81
|
+
|
|
82
|
+
`MqttClient` has the two implementations every port has, and both run
|
|
83
|
+
`src/clients/mqtt-client.suite.ts`:
|
|
84
|
+
|
|
85
|
+
- `createRealMqttClient()` is a stub in this build. Every method raises `VN8090`.
|
|
86
|
+
- `createFakeMqttClient({ seed })` keeps one queue per topic. A publish is recorded on `published`
|
|
87
|
+
(with its `qos`, `retain` and `will`) and enqueued, so a publish-then-expect flow resolves without a
|
|
88
|
+
broker. Subscriptions land on `subscriptions`.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { createFakeMqttClient } from "@venn-lang/mqtt";
|
|
92
|
+
|
|
93
|
+
const client = createFakeMqttClient();
|
|
94
|
+
await client.subscribe({ topic: "inventory/ack" });
|
|
95
|
+
await client.publish({ topic: "inventory/ack", payload: { ok: true }, qos: 1 });
|
|
96
|
+
const message = await client.expect({ topic: "inventory/ack" });
|
|
97
|
+
// message.payload === { ok: true }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`expect` on an empty topic raises `VN8091`. `@venn-lang/stdlib` binds the fake by default, so the flow in
|
|
101
|
+
[Usage](#usage) runs offline with no host wiring at all.
|
|
102
|
+
|
|
103
|
+
## See also
|
|
104
|
+
|
|
105
|
+
- [`@venn-lang/ws`](../std-ws), the same shape over a socket rather than topics.
|
|
106
|
+
- [`@venn-lang/http`](../std-http), the reference plugin, with a real client and a real server.
|
|
107
|
+
- [`@venn-lang/stdlib`](../stdlib) for the plugin list and the default port bindings.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Port } from "@venn-lang/contracts";
|
|
2
|
+
import { PluginDefinition, ZodType } from "@venn-lang/sdk";
|
|
3
|
+
import { TypeSpec } from "@venn-lang/types";
|
|
4
|
+
//#region src/types/message.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Runtime validation for `mqtt.Message`, registered on the plugin.
|
|
7
|
+
*
|
|
8
|
+
* Pairs with `mqttTypeDefs.Message`, which tells the checker the same shape:
|
|
9
|
+
* this one guards a value, that one types an expression.
|
|
10
|
+
*/
|
|
11
|
+
declare const messageSchema: ZodType;
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/types/message.types.d.ts
|
|
14
|
+
/** One message delivered on a topic, and what `mqtt.expect` hands back. */
|
|
15
|
+
interface MqttMessage {
|
|
16
|
+
topic: string;
|
|
17
|
+
payload: unknown;
|
|
18
|
+
qos?: number;
|
|
19
|
+
retain?: boolean;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/types/type-defs.d.ts
|
|
23
|
+
/**
|
|
24
|
+
* The types the plugin publishes to flows, as `mqtt.Message`.
|
|
25
|
+
*
|
|
26
|
+
* Mirrors `MqttMessage` in `message.types.ts` and the Zod schema beside it: the
|
|
27
|
+
* schema guards a value at runtime, this tells the checker what `mqtt.expect`
|
|
28
|
+
* handed back. Keep the three in step by hand.
|
|
29
|
+
*/
|
|
30
|
+
declare const mqttTypeDefs: Readonly<Record<string, TypeSpec>>;
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/port/mqtt-client.types.d.ts
|
|
33
|
+
/** What `mqtt.publish` passes on: the topic it was given, plus the opts map. */
|
|
34
|
+
interface MqttPublishArgs {
|
|
35
|
+
topic: string;
|
|
36
|
+
payload: unknown;
|
|
37
|
+
qos?: number;
|
|
38
|
+
retain?: boolean;
|
|
39
|
+
/** Last-will payload, stored alongside the publish. */
|
|
40
|
+
will?: unknown;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One broker connection: connect, publish, subscribe, and wait for a message.
|
|
44
|
+
*
|
|
45
|
+
* The port holds the connection, which is why no method takes a handle: a flow
|
|
46
|
+
* says `mqtt.publish "topic"` and the implementation knows which broker.
|
|
47
|
+
*
|
|
48
|
+
* Two implementations: `createRealMqttClient` and `createFakeMqttClient`.
|
|
49
|
+
*/
|
|
50
|
+
interface MqttClient {
|
|
51
|
+
connect(args: {
|
|
52
|
+
broker: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
publish(args: MqttPublishArgs): Promise<void>;
|
|
55
|
+
subscribe(args: {
|
|
56
|
+
topic: string;
|
|
57
|
+
}): Promise<void>;
|
|
58
|
+
expect(args: {
|
|
59
|
+
topic: string;
|
|
60
|
+
}): Promise<MqttMessage>;
|
|
61
|
+
}
|
|
62
|
+
/** An `MqttClient` that also lets a test read back what the flow asked for. */
|
|
63
|
+
interface FakeMqttClient extends MqttClient {
|
|
64
|
+
/** Every publish call, in order, including qos/retain/will. */
|
|
65
|
+
readonly published: readonly MqttPublishArgs[];
|
|
66
|
+
/** Every subscribed topic, in order. */
|
|
67
|
+
readonly subscriptions: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/port/mqtt-client.port.d.ts
|
|
71
|
+
/**
|
|
72
|
+
* The port descriptor every `mqtt` verb resolves through `ctx.port(...)`.
|
|
73
|
+
*
|
|
74
|
+
* Declares the `net` capability, so a host that cannot reach a broker refuses
|
|
75
|
+
* the binding at load time with a readable diagnostic.
|
|
76
|
+
*/
|
|
77
|
+
declare const MqttClientPort: Port<MqttClient>;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/clients/fake-client.d.ts
|
|
80
|
+
/**
|
|
81
|
+
* The double: a queue per topic, and no broker.
|
|
82
|
+
*
|
|
83
|
+
* A publish is also a delivery, so a flow that publishes and then expects on the
|
|
84
|
+
* same topic sees its own message. Topics are matched literally, with no
|
|
85
|
+
* wildcard expansion.
|
|
86
|
+
*
|
|
87
|
+
* @param args.seed Messages already waiting, keyed by topic.
|
|
88
|
+
* @returns A client whose `published` and `subscriptions` a test can read back.
|
|
89
|
+
* @throws VN8091 from `expect` when that topic's queue is empty.
|
|
90
|
+
*/
|
|
91
|
+
declare function createFakeMqttClient(args?: {
|
|
92
|
+
seed?: Record<string, MqttMessage[]>;
|
|
93
|
+
}): FakeMqttClient;
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/clients/real-client.d.ts
|
|
96
|
+
/**
|
|
97
|
+
* The real MQTT client. Not implemented in this build.
|
|
98
|
+
*
|
|
99
|
+
* It exists so the port has its second implementation and the failure is a named
|
|
100
|
+
* Venn error rather than a missing method.
|
|
101
|
+
*
|
|
102
|
+
* @throws VN8090 from every method.
|
|
103
|
+
*/
|
|
104
|
+
declare function createRealMqttClient(): MqttClient;
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/plugin.d.ts
|
|
107
|
+
/**
|
|
108
|
+
* The `mqtt` namespace: `connect`, `publish`, `subscribe`, `expect`, the `topic`
|
|
109
|
+
* matcher and the `mqtt.Message` type.
|
|
110
|
+
*
|
|
111
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
112
|
+
* time rather than failing mid-flow.
|
|
113
|
+
*/
|
|
114
|
+
declare const mqttPlugin: PluginDefinition;
|
|
115
|
+
//#endregion
|
|
116
|
+
export { type FakeMqttClient, type MqttClient, MqttClientPort, type MqttMessage, type MqttPublishArgs, createFakeMqttClient, createRealMqttClient, mqttPlugin as default, mqttPlugin, messageSchema, mqttTypeDefs };
|
|
117
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types/message.ts","../src/types/message.types.ts","../src/types/type-defs.ts","../src/port/mqtt-client.types.ts","../src/port/mqtt-client.port.ts","../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/plugin.ts"],"mappings":";;;;;;;;;;cAQa,eAAe;;;;UCPX;EACf;EACA;EACA;EACA;;;;;;;;;;;cCIW,cAAc,SAAS,eAAe;;;;UCNlC;EACf;EACA;EACA;EACA;;EAEA;;;;;;;;;;UAWe;EACf,QAAQ;IAAQ;MAAmB;EACnC,QAAQ,MAAM,kBAAkB;EAChC,UAAU;IAAQ;MAAkB;EACpC,OAAO;IAAQ;MAAkB,QAAQ;;;UAI1B,uBAAuB;;WAE7B,oBAAoB;;WAEpB;;;;;;;;;;cCvBE,gBAAgB,KAAK;;;;;;;;;;;;;;iBCMlB,qBACd;EAAQ,OAAO,eAAe;IAC7B;;;;;;;;;;;iBCNa,wBAAwB;;;;;;;;;;cCC3B,YAAY"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import { arg, defineAction, defineMatcher, definePlugin, z } from "@venn-lang/sdk";
|
|
3
|
+
import { t } from "@venn-lang/types";
|
|
4
|
+
//#region src/clients/fake-client.ts
|
|
5
|
+
/**
|
|
6
|
+
* The double: a queue per topic, and no broker.
|
|
7
|
+
*
|
|
8
|
+
* A publish is also a delivery, so a flow that publishes and then expects on the
|
|
9
|
+
* same topic sees its own message. Topics are matched literally, with no
|
|
10
|
+
* wildcard expansion.
|
|
11
|
+
*
|
|
12
|
+
* @param args.seed Messages already waiting, keyed by topic.
|
|
13
|
+
* @returns A client whose `published` and `subscriptions` a test can read back.
|
|
14
|
+
* @throws VN8091 from `expect` when that topic's queue is empty.
|
|
15
|
+
*/
|
|
16
|
+
function createFakeMqttClient(args = {}) {
|
|
17
|
+
const topics = seedTopics(args.seed);
|
|
18
|
+
const published = [];
|
|
19
|
+
const subscriptions = [];
|
|
20
|
+
return {
|
|
21
|
+
published,
|
|
22
|
+
subscriptions,
|
|
23
|
+
connect: async () => {},
|
|
24
|
+
publish: async (call) => recordPublish({
|
|
25
|
+
topics,
|
|
26
|
+
published,
|
|
27
|
+
call
|
|
28
|
+
}),
|
|
29
|
+
subscribe: async ({ topic }) => void subscriptions.push(topic),
|
|
30
|
+
expect: async ({ topic }) => dequeue(topics, topic)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function recordPublish(args) {
|
|
34
|
+
args.published.push(args.call);
|
|
35
|
+
enqueue(args.topics, toMessage(args.call));
|
|
36
|
+
}
|
|
37
|
+
function toMessage(call) {
|
|
38
|
+
return {
|
|
39
|
+
topic: call.topic,
|
|
40
|
+
payload: call.payload,
|
|
41
|
+
qos: call.qos,
|
|
42
|
+
retain: call.retain
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function enqueue(topics, message) {
|
|
46
|
+
const queue = topics.get(message.topic) ?? [];
|
|
47
|
+
queue.push(message);
|
|
48
|
+
topics.set(message.topic, queue);
|
|
49
|
+
}
|
|
50
|
+
function dequeue(topics, topic) {
|
|
51
|
+
const message = topics.get(topic)?.shift();
|
|
52
|
+
if (!message) throw noMessage(topic);
|
|
53
|
+
return message;
|
|
54
|
+
}
|
|
55
|
+
function seedTopics(seed = {}) {
|
|
56
|
+
return new Map(Object.entries(seed).map(([topic, messages]) => [topic, [...messages]]));
|
|
57
|
+
}
|
|
58
|
+
function noMessage(topic) {
|
|
59
|
+
return new VennError({
|
|
60
|
+
code: "VN8091",
|
|
61
|
+
message: `No message available on topic "${topic}".`,
|
|
62
|
+
detail: { topic }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/clients/real-client.ts
|
|
67
|
+
/**
|
|
68
|
+
* The real MQTT client. Not implemented in this build.
|
|
69
|
+
*
|
|
70
|
+
* It exists so the port has its second implementation and the failure is a named
|
|
71
|
+
* Venn error rather than a missing method.
|
|
72
|
+
*
|
|
73
|
+
* @throws VN8090 from every method.
|
|
74
|
+
*/
|
|
75
|
+
function createRealMqttClient() {
|
|
76
|
+
return {
|
|
77
|
+
connect: notImplemented,
|
|
78
|
+
publish: notImplemented,
|
|
79
|
+
subscribe: notImplemented,
|
|
80
|
+
expect: notImplemented
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function notImplemented() {
|
|
84
|
+
throw new VennError({
|
|
85
|
+
code: "VN8090",
|
|
86
|
+
message: "MQTT real client not implemented in this build"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/port/mqtt-client.port.ts
|
|
91
|
+
/**
|
|
92
|
+
* The port descriptor every `mqtt` verb resolves through `ctx.port(...)`.
|
|
93
|
+
*
|
|
94
|
+
* Declares the `net` capability, so a host that cannot reach a broker refuses
|
|
95
|
+
* the binding at load time with a readable diagnostic.
|
|
96
|
+
*/
|
|
97
|
+
const MqttClientPort = {
|
|
98
|
+
id: "venn.port.mqtt-client",
|
|
99
|
+
version: 1,
|
|
100
|
+
requires: ["net"],
|
|
101
|
+
methods: [
|
|
102
|
+
"connect",
|
|
103
|
+
"publish",
|
|
104
|
+
"subscribe",
|
|
105
|
+
"expect"
|
|
106
|
+
]
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/actions/connect.ts
|
|
110
|
+
/**
|
|
111
|
+
* `mqtt.connect "mqtt://broker.test"`: open the broker connection.
|
|
112
|
+
*
|
|
113
|
+
* Returns nothing, because the port owns the connection. That is what lets the
|
|
114
|
+
* other verbs reach it without the flow carrying a handle.
|
|
115
|
+
*/
|
|
116
|
+
const mqttConnect = defineAction({
|
|
117
|
+
name: "connect",
|
|
118
|
+
doc: "Connect to an MQTT broker.",
|
|
119
|
+
args: [arg("url", t.string, "Where the broker is.")],
|
|
120
|
+
result: t.void,
|
|
121
|
+
run: (ctx, input) => ctx.port(MqttClientPort).connect({ broker: String(input.args[0] ?? "") })
|
|
122
|
+
});
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/actions/expect.ts
|
|
125
|
+
/**
|
|
126
|
+
* `mqtt.expect "inventory/ack"`: wait for the next message on a topic.
|
|
127
|
+
*
|
|
128
|
+
* The result is typed as `mqtt.Message`, which is what lets
|
|
129
|
+
* `expect res topic "inventory/ack"` know its subject.
|
|
130
|
+
*/
|
|
131
|
+
const mqttExpect = defineAction({
|
|
132
|
+
name: "expect",
|
|
133
|
+
doc: "Wait for the next message on a topic.",
|
|
134
|
+
args: [arg("topic", t.string, "The topic, wildcards and all.")],
|
|
135
|
+
result: t.ref("mqtt.Message"),
|
|
136
|
+
run: (ctx, input) => ctx.port(MqttClientPort).expect({ topic: String(input.args[0] ?? "") })
|
|
137
|
+
});
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/actions/index.ts
|
|
140
|
+
/** The mqtt namespace's verbs. Adding one is a new file plus a single line here. */
|
|
141
|
+
const mqttActions = [
|
|
142
|
+
mqttConnect,
|
|
143
|
+
defineAction({
|
|
144
|
+
name: "publish",
|
|
145
|
+
doc: "Publish a message to a topic.",
|
|
146
|
+
params: z.object({
|
|
147
|
+
json: z.unknown().optional(),
|
|
148
|
+
qos: z.number().optional(),
|
|
149
|
+
retain: z.boolean().optional(),
|
|
150
|
+
will: z.unknown().optional()
|
|
151
|
+
}),
|
|
152
|
+
args: [arg("topic", t.string, "The topic, wildcards and all.")],
|
|
153
|
+
result: t.void,
|
|
154
|
+
run: (ctx, input) => ctx.port(MqttClientPort).publish({
|
|
155
|
+
topic: String(input.args[0] ?? ""),
|
|
156
|
+
payload: input.params.json,
|
|
157
|
+
qos: input.params.qos,
|
|
158
|
+
retain: input.params.retain,
|
|
159
|
+
will: input.params.will
|
|
160
|
+
})
|
|
161
|
+
}),
|
|
162
|
+
defineAction({
|
|
163
|
+
name: "subscribe",
|
|
164
|
+
doc: "Subscribe to a topic.",
|
|
165
|
+
args: [arg("topic", t.string, "The topic, wildcards and all.")],
|
|
166
|
+
result: t.void,
|
|
167
|
+
run: (ctx, input) => ctx.port(MqttClientPort).subscribe({ topic: String(input.args[0] ?? "") })
|
|
168
|
+
}),
|
|
169
|
+
mqttExpect
|
|
170
|
+
];
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/matchers/on-topic.ts
|
|
173
|
+
/**
|
|
174
|
+
* `expect res topic "inventory/ack"`: passes if the message arrived on exactly
|
|
175
|
+
* that topic. The comparison is literal, so wildcards are not expanded here.
|
|
176
|
+
*/
|
|
177
|
+
const onTopic = defineMatcher({
|
|
178
|
+
name: "topic",
|
|
179
|
+
args: [arg("topic", t.string, "The topic the message should have arrived on.")],
|
|
180
|
+
appliesTo: "Message",
|
|
181
|
+
test: ({ subject, args }) => messageTopic(subject) === String(args[0]),
|
|
182
|
+
message: ({ args }) => `expected the message on topic "${String(args[0])}"`
|
|
183
|
+
});
|
|
184
|
+
function messageTopic(subject) {
|
|
185
|
+
return subject.topic;
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/matchers/index.ts
|
|
189
|
+
const mqttMatchers = [onTopic];
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/types/message.ts
|
|
192
|
+
/**
|
|
193
|
+
* Runtime validation for `mqtt.Message`, registered on the plugin.
|
|
194
|
+
*
|
|
195
|
+
* Pairs with `mqttTypeDefs.Message`, which tells the checker the same shape:
|
|
196
|
+
* this one guards a value, that one types an expression.
|
|
197
|
+
*/
|
|
198
|
+
const messageSchema = z.object({
|
|
199
|
+
topic: z.string(),
|
|
200
|
+
payload: z.unknown(),
|
|
201
|
+
qos: z.number().optional(),
|
|
202
|
+
retain: z.boolean().optional()
|
|
203
|
+
});
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/types/type-defs.ts
|
|
206
|
+
/**
|
|
207
|
+
* The types the plugin publishes to flows, as `mqtt.Message`.
|
|
208
|
+
*
|
|
209
|
+
* Mirrors `MqttMessage` in `message.types.ts` and the Zod schema beside it: the
|
|
210
|
+
* schema guards a value at runtime, this tells the checker what `mqtt.expect`
|
|
211
|
+
* handed back. Keep the three in step by hand.
|
|
212
|
+
*/
|
|
213
|
+
const mqttTypeDefs = {
|
|
214
|
+
/** One message on a topic, as `mqtt.expect` gives it back. */
|
|
215
|
+
Message: t.record({
|
|
216
|
+
topic: t.string,
|
|
217
|
+
payload: t.dynamic,
|
|
218
|
+
qos: t.number,
|
|
219
|
+
retain: t.bool
|
|
220
|
+
}, { optional: ["qos", "retain"] }) };
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/plugin.ts
|
|
223
|
+
/**
|
|
224
|
+
* The `mqtt` namespace: `connect`, `publish`, `subscribe`, `expect`, the `topic`
|
|
225
|
+
* matcher and the `mqtt.Message` type.
|
|
226
|
+
*
|
|
227
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
228
|
+
* time rather than failing mid-flow.
|
|
229
|
+
*/
|
|
230
|
+
const mqttPlugin = definePlugin({
|
|
231
|
+
name: "venn/mqtt",
|
|
232
|
+
version: "0.0.0",
|
|
233
|
+
namespace: "mqtt",
|
|
234
|
+
requires: ["net"],
|
|
235
|
+
actions: mqttActions,
|
|
236
|
+
matchers: mqttMatchers,
|
|
237
|
+
types: { Message: messageSchema },
|
|
238
|
+
typeDefs: mqttTypeDefs
|
|
239
|
+
});
|
|
240
|
+
//#endregion
|
|
241
|
+
export { MqttClientPort, createFakeMqttClient, createRealMqttClient, mqttPlugin as default, mqttPlugin, messageSchema, mqttTypeDefs };
|
|
242
|
+
|
|
243
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/port/mqtt-client.port.ts","../src/actions/connect.ts","../src/actions/expect.ts","../src/actions/publish.ts","../src/actions/subscribe.ts","../src/actions/index.ts","../src/matchers/on-topic.ts","../src/matchers/index.ts","../src/types/message.ts","../src/types/type-defs.ts","../src/plugin.ts"],"sourcesContent":["import { VennError } from \"@venn-lang/contracts\";\nimport type { FakeMqttClient, MqttPublishArgs } from \"../port/index.js\";\nimport type { MqttMessage } from \"../types/index.js\";\n\n/**\n * The double: a queue per topic, and no broker.\n *\n * A publish is also a delivery, so a flow that publishes and then expects on the\n * same topic sees its own message. Topics are matched literally, with no\n * wildcard expansion.\n *\n * @param args.seed Messages already waiting, keyed by topic.\n * @returns A client whose `published` and `subscriptions` a test can read back.\n * @throws VN8091 from `expect` when that topic's queue is empty.\n */\nexport function createFakeMqttClient(\n args: { seed?: Record<string, MqttMessage[]> } = {},\n): FakeMqttClient {\n const topics = seedTopics(args.seed);\n const published: MqttPublishArgs[] = [];\n const subscriptions: string[] = [];\n return {\n published,\n subscriptions,\n connect: async () => {},\n publish: async (call) => recordPublish({ topics, published, call }),\n subscribe: async ({ topic }) => void subscriptions.push(topic),\n expect: async ({ topic }) => dequeue(topics, topic),\n };\n}\n\nfunction recordPublish(args: {\n topics: Map<string, MqttMessage[]>;\n published: MqttPublishArgs[];\n call: MqttPublishArgs;\n}): void {\n args.published.push(args.call);\n enqueue(args.topics, toMessage(args.call));\n}\n\nfunction toMessage(call: MqttPublishArgs): MqttMessage {\n return { topic: call.topic, payload: call.payload, qos: call.qos, retain: call.retain };\n}\n\nfunction enqueue(topics: Map<string, MqttMessage[]>, message: MqttMessage): void {\n const queue = topics.get(message.topic) ?? [];\n queue.push(message);\n topics.set(message.topic, queue);\n}\n\nfunction dequeue(topics: Map<string, MqttMessage[]>, topic: string): MqttMessage {\n const message = topics.get(topic)?.shift();\n if (!message) throw noMessage(topic);\n return message;\n}\n\nfunction seedTopics(seed: Record<string, MqttMessage[]> = {}): Map<string, MqttMessage[]> {\n return new Map(Object.entries(seed).map(([topic, messages]) => [topic, [...messages]]));\n}\n\nfunction noMessage(topic: string): VennError {\n return new VennError({\n code: \"VN8091\",\n message: `No message available on topic \"${topic}\".`,\n detail: { topic },\n });\n}\n","import { VennError } from \"@venn-lang/contracts\";\nimport type { MqttClient } from \"../port/index.js\";\n\n/**\n * The real MQTT client. Not implemented in this build.\n *\n * It exists so the port has its second implementation and the failure is a named\n * Venn error rather than a missing method.\n *\n * @throws VN8090 from every method.\n */\nexport function createRealMqttClient(): MqttClient {\n return {\n connect: notImplemented,\n publish: notImplemented,\n subscribe: notImplemented,\n expect: notImplemented,\n };\n}\n\nfunction notImplemented(): never {\n throw new VennError({\n code: \"VN8090\",\n message: \"MQTT real client not implemented in this build\",\n });\n}\n","import type { Port } from \"@venn-lang/contracts\";\nimport type { MqttClient } from \"./mqtt-client.types.js\";\n\n/**\n * The port descriptor every `mqtt` verb resolves through `ctx.port(...)`.\n *\n * Declares the `net` capability, so a host that cannot reach a broker refuses\n * the binding at load time with a readable diagnostic.\n */\nexport const MqttClientPort: Port<MqttClient> = {\n id: \"venn.port.mqtt-client\",\n version: 1,\n requires: [\"net\"],\n methods: [\"connect\", \"publish\", \"subscribe\", \"expect\"],\n};\n","import { type ActionDefinition, arg, defineAction } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { MqttClientPort } from \"../port/index.js\";\n\n/**\n * `mqtt.connect \"mqtt://broker.test\"`: open the broker connection.\n *\n * Returns nothing, because the port owns the connection. That is what lets the\n * other verbs reach it without the flow carrying a handle.\n */\nexport const mqttConnect: ActionDefinition = defineAction({\n name: \"connect\",\n doc: \"Connect to an MQTT broker.\",\n args: [arg(\"url\", t.string, \"Where the broker is.\")],\n result: t.void,\n run: (ctx, input) => ctx.port(MqttClientPort).connect({ broker: String(input.args[0] ?? \"\") }),\n});\n","import { type ActionDefinition, arg, defineAction } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { MqttClientPort } from \"../port/index.js\";\n\n/**\n * `mqtt.expect \"inventory/ack\"`: wait for the next message on a topic.\n *\n * The result is typed as `mqtt.Message`, which is what lets\n * `expect res topic \"inventory/ack\"` know its subject.\n */\nexport const mqttExpect: ActionDefinition = defineAction({\n name: \"expect\",\n doc: \"Wait for the next message on a topic.\",\n args: [arg(\"topic\", t.string, \"The topic, wildcards and all.\")],\n result: t.ref(\"mqtt.Message\"),\n run: (ctx, input) => ctx.port(MqttClientPort).expect({ topic: String(input.args[0] ?? \"\") }),\n});\n","import { type ActionDefinition, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { MqttClientPort } from \"../port/index.js\";\n\nconst params = z.object({\n json: z.unknown().optional(),\n qos: z.number().optional(),\n retain: z.boolean().optional(),\n will: z.unknown().optional(),\n});\n\n/**\n * `mqtt.publish \"inventory/sku-42\" { json: { … }, qos: 1 }`: send one message.\n *\n * The topic is the only positional argument; the payload and the delivery\n * options ride the map, which `params` above describes.\n */\nexport const mqttPublish: ActionDefinition = defineAction({\n name: \"publish\",\n doc: \"Publish a message to a topic.\",\n params,\n args: [arg(\"topic\", t.string, \"The topic, wildcards and all.\")],\n result: t.void,\n run: (ctx, input) =>\n ctx.port(MqttClientPort).publish({\n topic: String(input.args[0] ?? \"\"),\n payload: input.params.json,\n qos: input.params.qos,\n retain: input.params.retain,\n will: input.params.will,\n }),\n});\n","import { type ActionDefinition, arg, defineAction } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { MqttClientPort } from \"../port/index.js\";\n\n/**\n * `mqtt.subscribe \"inventory/#\"`: start receiving on a topic.\n *\n * Returns nothing: the port remembers the subscription, and `mqtt.expect` is how\n * a flow reaches what arrived.\n */\nexport const mqttSubscribe: ActionDefinition = defineAction({\n name: \"subscribe\",\n doc: \"Subscribe to a topic.\",\n args: [arg(\"topic\", t.string, \"The topic, wildcards and all.\")],\n result: t.void,\n run: (ctx, input) => ctx.port(MqttClientPort).subscribe({ topic: String(input.args[0] ?? \"\") }),\n});\n","import type { ActionDefinition } from \"@venn-lang/sdk\";\nimport { mqttConnect } from \"./connect.js\";\nimport { mqttExpect } from \"./expect.js\";\nimport { mqttPublish } from \"./publish.js\";\nimport { mqttSubscribe } from \"./subscribe.js\";\n\n/** The mqtt namespace's verbs. Adding one is a new file plus a single line here. */\nexport const mqttActions: ActionDefinition[] = [\n mqttConnect,\n mqttPublish,\n mqttSubscribe,\n mqttExpect,\n];\n","import { arg, defineMatcher, type MatcherDefinition } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\n\n/**\n * `expect res topic \"inventory/ack\"`: passes if the message arrived on exactly\n * that topic. The comparison is literal, so wildcards are not expanded here.\n */\nexport const onTopic: MatcherDefinition = defineMatcher({\n name: \"topic\",\n args: [arg(\"topic\", t.string, \"The topic the message should have arrived on.\")],\n appliesTo: \"Message\",\n test: ({ subject, args }) => messageTopic(subject) === String(args[0]),\n message: ({ args }) => `expected the message on topic \"${String(args[0])}\"`,\n});\n\nfunction messageTopic(subject: unknown): string | undefined {\n return (subject as { topic?: string }).topic;\n}\n","import type { MatcherDefinition } from \"@venn-lang/sdk\";\nimport { onTopic } from \"./on-topic.js\";\n\nexport const mqttMatchers: MatcherDefinition[] = [onTopic];\n","import { type ZodType, z } from \"@venn-lang/sdk\";\n\n/**\n * Runtime validation for `mqtt.Message`, registered on the plugin.\n *\n * Pairs with `mqttTypeDefs.Message`, which tells the checker the same shape:\n * this one guards a value, that one types an expression.\n */\nexport const messageSchema: ZodType = z.object({\n topic: z.string(),\n payload: z.unknown(),\n qos: z.number().optional(),\n retain: z.boolean().optional(),\n});\n","import { type TypeSpec, t } from \"@venn-lang/types\";\n\n/**\n * The types the plugin publishes to flows, as `mqtt.Message`.\n *\n * Mirrors `MqttMessage` in `message.types.ts` and the Zod schema beside it: the\n * schema guards a value at runtime, this tells the checker what `mqtt.expect`\n * handed back. Keep the three in step by hand.\n */\nexport const mqttTypeDefs: Readonly<Record<string, TypeSpec>> = {\n /** One message on a topic, as `mqtt.expect` gives it back. */\n Message: t.record(\n { topic: t.string, payload: t.dynamic, qos: t.number, retain: t.bool },\n { optional: [\"qos\", \"retain\"] },\n ),\n};\n","import { definePlugin, type PluginDefinition } from \"@venn-lang/sdk\";\nimport { mqttActions } from \"./actions/index.js\";\nimport { mqttMatchers } from \"./matchers/index.js\";\nimport { messageSchema, mqttTypeDefs } from \"./types/index.js\";\n\n/**\n * The `mqtt` namespace: `connect`, `publish`, `subscribe`, `expect`, the `topic`\n * matcher and the `mqtt.Message` type.\n *\n * Requires the `net` capability, so a host without it refuses the plugin at load\n * time rather than failing mid-flow.\n */\nexport const mqttPlugin: PluginDefinition = definePlugin({\n name: \"venn/mqtt\",\n version: \"0.0.0\",\n namespace: \"mqtt\",\n requires: [\"net\"],\n actions: mqttActions,\n matchers: mqttMatchers,\n types: { Message: messageSchema },\n typeDefs: mqttTypeDefs,\n});\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@venn-lang/mqtt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The mqtt namespace: connect to a broker, publish, subscribe, wait for a message on a topic.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"venn",
|
|
7
|
+
"testing",
|
|
8
|
+
"e2e",
|
|
9
|
+
"mqtt"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-mqtt#readme",
|
|
12
|
+
"bugs": "https://github.com/venn-lang/venn/issues",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/venn-lang/venn.git",
|
|
16
|
+
"directory": "packages/std-mqtt"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Vinicius Borges",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"development": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"!src/**/*.test.ts",
|
|
34
|
+
"!src/**/*.suite.ts"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@venn-lang/contracts": "0.1.0",
|
|
41
|
+
"@venn-lang/sdk": "0.1.0",
|
|
42
|
+
"@venn-lang/types": "0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"tsdown": "^0.22.14",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vitest": "^4.1.10"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"typecheck": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { MqttClientPort } from "../port/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `mqtt.connect "mqtt://broker.test"`: open the broker connection.
|
|
7
|
+
*
|
|
8
|
+
* Returns nothing, because the port owns the connection. That is what lets the
|
|
9
|
+
* other verbs reach it without the flow carrying a handle.
|
|
10
|
+
*/
|
|
11
|
+
export const mqttConnect: ActionDefinition = defineAction({
|
|
12
|
+
name: "connect",
|
|
13
|
+
doc: "Connect to an MQTT broker.",
|
|
14
|
+
args: [arg("url", t.string, "Where the broker is.")],
|
|
15
|
+
result: t.void,
|
|
16
|
+
run: (ctx, input) => ctx.port(MqttClientPort).connect({ broker: String(input.args[0] ?? "") }),
|
|
17
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { MqttClientPort } from "../port/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `mqtt.expect "inventory/ack"`: wait for the next message on a topic.
|
|
7
|
+
*
|
|
8
|
+
* The result is typed as `mqtt.Message`, which is what lets
|
|
9
|
+
* `expect res topic "inventory/ack"` know its subject.
|
|
10
|
+
*/
|
|
11
|
+
export const mqttExpect: ActionDefinition = defineAction({
|
|
12
|
+
name: "expect",
|
|
13
|
+
doc: "Wait for the next message on a topic.",
|
|
14
|
+
args: [arg("topic", t.string, "The topic, wildcards and all.")],
|
|
15
|
+
result: t.ref("mqtt.Message"),
|
|
16
|
+
run: (ctx, input) => ctx.port(MqttClientPort).expect({ topic: String(input.args[0] ?? "") }),
|
|
17
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ActionDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { mqttConnect } from "./connect.js";
|
|
3
|
+
import { mqttExpect } from "./expect.js";
|
|
4
|
+
import { mqttPublish } from "./publish.js";
|
|
5
|
+
import { mqttSubscribe } from "./subscribe.js";
|
|
6
|
+
|
|
7
|
+
/** The mqtt namespace's verbs. Adding one is a new file plus a single line here. */
|
|
8
|
+
export const mqttActions: ActionDefinition[] = [
|
|
9
|
+
mqttConnect,
|
|
10
|
+
mqttPublish,
|
|
11
|
+
mqttSubscribe,
|
|
12
|
+
mqttExpect,
|
|
13
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { MqttClientPort } from "../port/index.js";
|
|
4
|
+
|
|
5
|
+
const params = z.object({
|
|
6
|
+
json: z.unknown().optional(),
|
|
7
|
+
qos: z.number().optional(),
|
|
8
|
+
retain: z.boolean().optional(),
|
|
9
|
+
will: z.unknown().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `mqtt.publish "inventory/sku-42" { json: { … }, qos: 1 }`: send one message.
|
|
14
|
+
*
|
|
15
|
+
* The topic is the only positional argument; the payload and the delivery
|
|
16
|
+
* options ride the map, which `params` above describes.
|
|
17
|
+
*/
|
|
18
|
+
export const mqttPublish: ActionDefinition = defineAction({
|
|
19
|
+
name: "publish",
|
|
20
|
+
doc: "Publish a message to a topic.",
|
|
21
|
+
params,
|
|
22
|
+
args: [arg("topic", t.string, "The topic, wildcards and all.")],
|
|
23
|
+
result: t.void,
|
|
24
|
+
run: (ctx, input) =>
|
|
25
|
+
ctx.port(MqttClientPort).publish({
|
|
26
|
+
topic: String(input.args[0] ?? ""),
|
|
27
|
+
payload: input.params.json,
|
|
28
|
+
qos: input.params.qos,
|
|
29
|
+
retain: input.params.retain,
|
|
30
|
+
will: input.params.will,
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { MqttClientPort } from "../port/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `mqtt.subscribe "inventory/#"`: start receiving on a topic.
|
|
7
|
+
*
|
|
8
|
+
* Returns nothing: the port remembers the subscription, and `mqtt.expect` is how
|
|
9
|
+
* a flow reaches what arrived.
|
|
10
|
+
*/
|
|
11
|
+
export const mqttSubscribe: ActionDefinition = defineAction({
|
|
12
|
+
name: "subscribe",
|
|
13
|
+
doc: "Subscribe to a topic.",
|
|
14
|
+
args: [arg("topic", t.string, "The topic, wildcards and all.")],
|
|
15
|
+
result: t.void,
|
|
16
|
+
run: (ctx, input) => ctx.port(MqttClientPort).subscribe({ topic: String(input.args[0] ?? "") }),
|
|
17
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import type { FakeMqttClient, MqttPublishArgs } from "../port/index.js";
|
|
3
|
+
import type { MqttMessage } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The double: a queue per topic, and no broker.
|
|
7
|
+
*
|
|
8
|
+
* A publish is also a delivery, so a flow that publishes and then expects on the
|
|
9
|
+
* same topic sees its own message. Topics are matched literally, with no
|
|
10
|
+
* wildcard expansion.
|
|
11
|
+
*
|
|
12
|
+
* @param args.seed Messages already waiting, keyed by topic.
|
|
13
|
+
* @returns A client whose `published` and `subscriptions` a test can read back.
|
|
14
|
+
* @throws VN8091 from `expect` when that topic's queue is empty.
|
|
15
|
+
*/
|
|
16
|
+
export function createFakeMqttClient(
|
|
17
|
+
args: { seed?: Record<string, MqttMessage[]> } = {},
|
|
18
|
+
): FakeMqttClient {
|
|
19
|
+
const topics = seedTopics(args.seed);
|
|
20
|
+
const published: MqttPublishArgs[] = [];
|
|
21
|
+
const subscriptions: string[] = [];
|
|
22
|
+
return {
|
|
23
|
+
published,
|
|
24
|
+
subscriptions,
|
|
25
|
+
connect: async () => {},
|
|
26
|
+
publish: async (call) => recordPublish({ topics, published, call }),
|
|
27
|
+
subscribe: async ({ topic }) => void subscriptions.push(topic),
|
|
28
|
+
expect: async ({ topic }) => dequeue(topics, topic),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recordPublish(args: {
|
|
33
|
+
topics: Map<string, MqttMessage[]>;
|
|
34
|
+
published: MqttPublishArgs[];
|
|
35
|
+
call: MqttPublishArgs;
|
|
36
|
+
}): void {
|
|
37
|
+
args.published.push(args.call);
|
|
38
|
+
enqueue(args.topics, toMessage(args.call));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toMessage(call: MqttPublishArgs): MqttMessage {
|
|
42
|
+
return { topic: call.topic, payload: call.payload, qos: call.qos, retain: call.retain };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function enqueue(topics: Map<string, MqttMessage[]>, message: MqttMessage): void {
|
|
46
|
+
const queue = topics.get(message.topic) ?? [];
|
|
47
|
+
queue.push(message);
|
|
48
|
+
topics.set(message.topic, queue);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dequeue(topics: Map<string, MqttMessage[]>, topic: string): MqttMessage {
|
|
52
|
+
const message = topics.get(topic)?.shift();
|
|
53
|
+
if (!message) throw noMessage(topic);
|
|
54
|
+
return message;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function seedTopics(seed: Record<string, MqttMessage[]> = {}): Map<string, MqttMessage[]> {
|
|
58
|
+
return new Map(Object.entries(seed).map(([topic, messages]) => [topic, [...messages]]));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function noMessage(topic: string): VennError {
|
|
62
|
+
return new VennError({
|
|
63
|
+
code: "VN8091",
|
|
64
|
+
message: `No message available on topic "${topic}".`,
|
|
65
|
+
detail: { topic },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import type { MqttClient } from "../port/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The real MQTT client. Not implemented in this build.
|
|
6
|
+
*
|
|
7
|
+
* It exists so the port has its second implementation and the failure is a named
|
|
8
|
+
* Venn error rather than a missing method.
|
|
9
|
+
*
|
|
10
|
+
* @throws VN8090 from every method.
|
|
11
|
+
*/
|
|
12
|
+
export function createRealMqttClient(): MqttClient {
|
|
13
|
+
return {
|
|
14
|
+
connect: notImplemented,
|
|
15
|
+
publish: notImplemented,
|
|
16
|
+
subscribe: notImplemented,
|
|
17
|
+
expect: notImplemented,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function notImplemented(): never {
|
|
22
|
+
throw new VennError({
|
|
23
|
+
code: "VN8090",
|
|
24
|
+
message: "MQTT real client not implemented in this build",
|
|
25
|
+
});
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mqtt` plugin: `connect`, `publish`, `subscribe` and `expect`.
|
|
3
|
+
*
|
|
4
|
+
* The broker connection lives behind the `MqttClient` port, so a flow never
|
|
5
|
+
* carries a handle and the same script runs against a real broker or the
|
|
6
|
+
* in-memory double.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from "./clients/index.js";
|
|
10
|
+
export { mqttPlugin, mqttPlugin as default } from "./plugin.js";
|
|
11
|
+
export * from "./port/index.js";
|
|
12
|
+
export * from "./types/index.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { arg, defineMatcher, type MatcherDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `expect res topic "inventory/ack"`: passes if the message arrived on exactly
|
|
6
|
+
* that topic. The comparison is literal, so wildcards are not expanded here.
|
|
7
|
+
*/
|
|
8
|
+
export const onTopic: MatcherDefinition = defineMatcher({
|
|
9
|
+
name: "topic",
|
|
10
|
+
args: [arg("topic", t.string, "The topic the message should have arrived on.")],
|
|
11
|
+
appliesTo: "Message",
|
|
12
|
+
test: ({ subject, args }) => messageTopic(subject) === String(args[0]),
|
|
13
|
+
message: ({ args }) => `expected the message on topic "${String(args[0])}"`,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function messageTopic(subject: unknown): string | undefined {
|
|
17
|
+
return (subject as { topic?: string }).topic;
|
|
18
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { mqttActions } from "./actions/index.js";
|
|
3
|
+
import { mqttMatchers } from "./matchers/index.js";
|
|
4
|
+
import { messageSchema, mqttTypeDefs } from "./types/index.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `mqtt` namespace: `connect`, `publish`, `subscribe`, `expect`, the `topic`
|
|
8
|
+
* matcher and the `mqtt.Message` type.
|
|
9
|
+
*
|
|
10
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
11
|
+
* time rather than failing mid-flow.
|
|
12
|
+
*/
|
|
13
|
+
export const mqttPlugin: PluginDefinition = definePlugin({
|
|
14
|
+
name: "venn/mqtt",
|
|
15
|
+
version: "0.0.0",
|
|
16
|
+
namespace: "mqtt",
|
|
17
|
+
requires: ["net"],
|
|
18
|
+
actions: mqttActions,
|
|
19
|
+
matchers: mqttMatchers,
|
|
20
|
+
types: { Message: messageSchema },
|
|
21
|
+
typeDefs: mqttTypeDefs,
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Port } from "@venn-lang/contracts";
|
|
2
|
+
import type { MqttClient } from "./mqtt-client.types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The port descriptor every `mqtt` verb resolves through `ctx.port(...)`.
|
|
6
|
+
*
|
|
7
|
+
* Declares the `net` capability, so a host that cannot reach a broker refuses
|
|
8
|
+
* the binding at load time with a readable diagnostic.
|
|
9
|
+
*/
|
|
10
|
+
export const MqttClientPort: Port<MqttClient> = {
|
|
11
|
+
id: "venn.port.mqtt-client",
|
|
12
|
+
version: 1,
|
|
13
|
+
requires: ["net"],
|
|
14
|
+
methods: ["connect", "publish", "subscribe", "expect"],
|
|
15
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MqttMessage } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
/** What `mqtt.publish` passes on: the topic it was given, plus the opts map. */
|
|
4
|
+
export interface MqttPublishArgs {
|
|
5
|
+
topic: string;
|
|
6
|
+
payload: unknown;
|
|
7
|
+
qos?: number;
|
|
8
|
+
retain?: boolean;
|
|
9
|
+
/** Last-will payload, stored alongside the publish. */
|
|
10
|
+
will?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One broker connection: connect, publish, subscribe, and wait for a message.
|
|
15
|
+
*
|
|
16
|
+
* The port holds the connection, which is why no method takes a handle: a flow
|
|
17
|
+
* says `mqtt.publish "topic"` and the implementation knows which broker.
|
|
18
|
+
*
|
|
19
|
+
* Two implementations: `createRealMqttClient` and `createFakeMqttClient`.
|
|
20
|
+
*/
|
|
21
|
+
export interface MqttClient {
|
|
22
|
+
connect(args: { broker: string }): Promise<void>;
|
|
23
|
+
publish(args: MqttPublishArgs): Promise<void>;
|
|
24
|
+
subscribe(args: { topic: string }): Promise<void>;
|
|
25
|
+
expect(args: { topic: string }): Promise<MqttMessage>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An `MqttClient` that also lets a test read back what the flow asked for. */
|
|
29
|
+
export interface FakeMqttClient extends MqttClient {
|
|
30
|
+
/** Every publish call, in order, including qos/retain/will. */
|
|
31
|
+
readonly published: readonly MqttPublishArgs[];
|
|
32
|
+
/** Every subscribed topic, in order. */
|
|
33
|
+
readonly subscriptions: readonly string[];
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ZodType, z } from "@venn-lang/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runtime validation for `mqtt.Message`, registered on the plugin.
|
|
5
|
+
*
|
|
6
|
+
* Pairs with `mqttTypeDefs.Message`, which tells the checker the same shape:
|
|
7
|
+
* this one guards a value, that one types an expression.
|
|
8
|
+
*/
|
|
9
|
+
export const messageSchema: ZodType = z.object({
|
|
10
|
+
topic: z.string(),
|
|
11
|
+
payload: z.unknown(),
|
|
12
|
+
qos: z.number().optional(),
|
|
13
|
+
retain: z.boolean().optional(),
|
|
14
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type TypeSpec, t } from "@venn-lang/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The types the plugin publishes to flows, as `mqtt.Message`.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `MqttMessage` in `message.types.ts` and the Zod schema beside it: the
|
|
7
|
+
* schema guards a value at runtime, this tells the checker what `mqtt.expect`
|
|
8
|
+
* handed back. Keep the three in step by hand.
|
|
9
|
+
*/
|
|
10
|
+
export const mqttTypeDefs: Readonly<Record<string, TypeSpec>> = {
|
|
11
|
+
/** One message on a topic, as `mqtt.expect` gives it back. */
|
|
12
|
+
Message: t.record(
|
|
13
|
+
{ topic: t.string, payload: t.dynamic, qos: t.number, retain: t.bool },
|
|
14
|
+
{ optional: ["qos", "retain"] },
|
|
15
|
+
),
|
|
16
|
+
};
|