@venn-lang/ws 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 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,106 @@
1
+ # @venn-lang/ws
2
+
3
+ > The `ws` namespace: open a WebSocket, send, wait for a message, close.
4
+
5
+ Venn's grammar knows no verbs. `@venn-lang/ws` registers the `ws` namespace with the runtime, so
6
+ `ws.connect` resolves to an action and `ws.expect` hands back a typed message. The socket itself lives
7
+ behind the `WsClient` port, not in a handle the flow carries around, so a host swaps the whole
8
+ 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/ws"
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ module demo.stream
23
+
24
+ use "venn/ws"
25
+
26
+ flow "Stock stream" {
27
+ step "the socket accepts the subscription" {
28
+ ws.connect "wss://example.test/stream" { auth: "token" }
29
+ ws.send { type: "subscribe", data: { sku: "sku-42" } }
30
+ ws.close
31
+ }
32
+ }
33
+ ```
34
+
35
+ ## API
36
+
37
+ Everything below is exported from the package barrel.
38
+
39
+ | Export | What it is |
40
+ | --- | --- |
41
+ | `wsPlugin` (also the default export) | The `PluginDefinition`: namespace `ws`, `requires: ["net"]`. |
42
+ | `WsClientPort` | `Port<WsClient>`, id `venn.port.ws-client`, version 1, methods `connect`, `send`, `expect`, `close`. |
43
+ | `createFakeWsClient({ incoming })` | The double: preloaded messages in, sent messages recorded on `sent`. |
44
+ | `createRealWsClient()` | The real client. Out of scope for this build: every method throws `VN8090`. |
45
+ | `messageSchema` | The Zod schema behind the nominal `Message` type the plugin registers. |
46
+ | `wsTypeDefs` | What the plugin publishes to the checker, as `TypeSpec` data. |
47
+
48
+ Types: `WsClient`, `FakeWsClient`, `WsConnectArgs`, `WsExpectQuery`, `Message`.
49
+
50
+ ## Verbs
51
+
52
+ | Verb | Shape | Result |
53
+ | --- | --- | --- |
54
+ | `ws.connect` | `ws.connect url { auth }` | nothing |
55
+ | `ws.send` | `ws.send { type, data }` | nothing |
56
+ | `ws.expect` | `ws.expect { type }` or `ws.expect { where }` | `ws.Message` |
57
+ | `ws.close` | `ws.close` | nothing |
58
+
59
+ Only `ws.connect` has a positional argument, the URL. For `ws.send` the message *is* the options map.
60
+ For `ws.expect` the query is: `type` matches the message type, `where` matches field by field, and the
61
+ first message that satisfies it is consumed and returned.
62
+
63
+ ## Matchers and types
64
+
65
+ The plugin publishes one type, `ws.Message`: an open record with optional `type` and `data`. It is
66
+ open because `expect { where: … }` matches on whatever fields a message actually carries beyond those
67
+ two.
68
+
69
+ A matcher named `type` is registered against `Message`, but `type` is a grammar keyword, so it cannot
70
+ be spelled after `expect` today. Read the field instead:
71
+
72
+ ```ruby
73
+ const msg = ws.expect { type: "ack" }
74
+ expect msg.type == "ack"
75
+ ```
76
+
77
+ ## Ports and conformance
78
+
79
+ `WsClient` has the two implementations every port has, and both run
80
+ `src/clients/ws-client.suite.ts`:
81
+
82
+ - `createRealWsClient()` is a stub in this build. Every method raises `VN8090`.
83
+ - `createFakeWsClient({ incoming })` resolves `expect` from a preloaded queue and records every
84
+ `send` on `sent`, so a test asserts on what the flow put on the wire without a wire:
85
+
86
+ ```ts
87
+ import { createFakeWsClient } from "@venn-lang/ws";
88
+
89
+ const client = createFakeWsClient({ incoming: [{ type: "ack", data: { ok: true } }] });
90
+ await client.connect({ url: "wss://example.test", auth: "token" });
91
+ await client.send({ type: "ping", data: 1 });
92
+ // client.sent === [{ type: "ping", data: 1 }]
93
+ ```
94
+
95
+ `expect` prefers the matching message over the first one, and falls back to the first when nothing
96
+ matches. An empty queue raises `VN8091`.
97
+
98
+ That matters for `ws.expect` in a `.vn` file: `@venn-lang/stdlib` binds `createFakeWsClient({ incoming: [] })`
99
+ by default, so the verb has nothing to resolve and fails with `VN8091` until a host binds a client
100
+ seeded with messages.
101
+
102
+ ## See also
103
+
104
+ - [`@venn-lang/mqtt`](../std-mqtt), the same shape over topics rather than a socket.
105
+ - [`@venn-lang/http`](../std-http), the reference plugin, with a real client and a real server.
106
+ - [`@venn-lang/stdlib`](../stdlib) for the plugin list and the default port bindings.
@@ -0,0 +1,113 @@
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 `ws.Message`, registered on the plugin.
7
+ *
8
+ * Pairs with `wsTypeDefs.Message`, which tells the checker the same shape: this
9
+ * 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
+ /**
15
+ * One message on the socket, and what `ws.expect` hands back.
16
+ *
17
+ * Both fields are optional because a peer is free to send whatever it likes;
18
+ * `where` queries match on fields beyond these two.
19
+ */
20
+ interface Message {
21
+ type?: string;
22
+ data?: unknown;
23
+ }
24
+ //#endregion
25
+ //#region src/types/type-defs.d.ts
26
+ /**
27
+ * The types the plugin publishes to flows, as `ws.Message`.
28
+ *
29
+ * Mirrors `Message` in `message.types.ts` and the Zod schema beside it: the
30
+ * schema guards a value at runtime, this tells the checker what `ws.expect`
31
+ * handed back. Open, because `expect { where: … }` matches on whatever fields a
32
+ * message carries beyond `type` and `data`.
33
+ */
34
+ declare const wsTypeDefs: Readonly<Record<string, TypeSpec>>;
35
+ //#endregion
36
+ //#region src/port/ws-client.types.d.ts
37
+ /** What `ws.connect` passes on: the URL it was given, and any `auth` from the opts map. */
38
+ interface WsConnectArgs {
39
+ url: string;
40
+ auth?: unknown;
41
+ }
42
+ /**
43
+ * Which incoming message `ws.expect` is waiting for: one with this `type`, or
44
+ * one whose fields match every entry in `where`. Both empty means the next one.
45
+ */
46
+ interface WsExpectQuery {
47
+ type?: string;
48
+ where?: Record<string, unknown>;
49
+ }
50
+ /**
51
+ * One WebSocket connection: open it, write to it, wait on it, close it.
52
+ *
53
+ * The port holds the socket, which is why no method takes a handle: a flow says
54
+ * `ws.send { … }` and the implementation knows which connection it means.
55
+ *
56
+ * Two implementations: `createRealWsClient` and `createFakeWsClient`.
57
+ */
58
+ interface WsClient {
59
+ connect(args: WsConnectArgs): Promise<void>;
60
+ send(message: Message): Promise<void>;
61
+ expect(query: WsExpectQuery): Promise<Message>;
62
+ close(): Promise<void>;
63
+ }
64
+ /** A `WsClient` that also lets a test read back what the flow sent. */
65
+ interface FakeWsClient extends WsClient {
66
+ /** Every message handed to `send`, in order. */
67
+ readonly sent: readonly Message[];
68
+ }
69
+ //#endregion
70
+ //#region src/port/ws-client.port.d.ts
71
+ /**
72
+ * The port descriptor every `ws` verb resolves through `ctx.port(...)`.
73
+ *
74
+ * Declares the `net` capability, so a host that cannot open sockets refuses the
75
+ * binding at load time with a readable diagnostic.
76
+ */
77
+ declare const WsClientPort: Port<WsClient>;
78
+ //#endregion
79
+ //#region src/clients/fake-client.d.ts
80
+ /**
81
+ * The double: no socket, and `expect` resolves at once instead of waiting.
82
+ *
83
+ * @param args.incoming Messages the peer is pretending to have sent, in order.
84
+ * @returns A client whose `sent` array is everything the flow wrote.
85
+ * @throws VN8091 from `expect` once the incoming queue is empty.
86
+ */
87
+ declare function createFakeWsClient(args?: {
88
+ incoming?: Message[];
89
+ }): FakeWsClient;
90
+ //#endregion
91
+ //#region src/clients/real-client.d.ts
92
+ /**
93
+ * The real WebSocket client. Not implemented in this build.
94
+ *
95
+ * It exists so the port has its second implementation and the failure is a named
96
+ * Venn error rather than a missing method.
97
+ *
98
+ * @throws VN8090 from every method.
99
+ */
100
+ declare function createRealWsClient(): WsClient;
101
+ //#endregion
102
+ //#region src/plugin.d.ts
103
+ /**
104
+ * The `ws` namespace: `connect`, `send`, `expect`, `close`, the `type` matcher
105
+ * and the `ws.Message` type.
106
+ *
107
+ * Requires the `net` capability, so a host without it refuses the plugin at load
108
+ * time rather than failing mid-flow.
109
+ */
110
+ declare const wsPlugin: PluginDefinition;
111
+ //#endregion
112
+ export { type FakeWsClient, type Message, type WsClient, WsClientPort, type WsConnectArgs, type WsExpectQuery, createFakeWsClient, createRealWsClient, wsPlugin as default, wsPlugin, messageSchema, wsTypeDefs };
113
+ //# 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/ws-client.types.ts","../src/port/ws-client.port.ts","../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/plugin.ts"],"mappings":";;;;;;;;;;cAQa,eAAe;;;;;;;;;UCFX;EACf;EACA;;;;;;;;;;;;cCEW,YAAY,SAAS,eAAe;;;;UCPhC;EACf;EACA;;;;;;UAOe;EACf;EACA,QAAQ;;;;;;;;;;UAWO;EACf,QAAQ,MAAM,gBAAgB;EAC9B,KAAK,SAAS,UAAU;EACxB,OAAO,OAAO,gBAAgB,QAAQ;EACtC,SAAS;;;UAIM,qBAAqB;;WAE3B,eAAe;;;;;;;;;;cC1Bb,cAAc,KAAK;;;;;;;;;;iBCEhB,mBAAmB;EAAQ,WAAW;IAAmB;;;;;;;;;;;iBCAzD,sBAAsB;;;;;;;;;;cCCzB,UAAU"}
package/dist/index.js ADDED
@@ -0,0 +1,224 @@
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: no socket, and `expect` resolves at once instead of waiting.
7
+ *
8
+ * @param args.incoming Messages the peer is pretending to have sent, in order.
9
+ * @returns A client whose `sent` array is everything the flow wrote.
10
+ * @throws VN8091 from `expect` once the incoming queue is empty.
11
+ */
12
+ function createFakeWsClient(args = {}) {
13
+ const incoming = [...args.incoming ?? []];
14
+ const sent = [];
15
+ return {
16
+ sent,
17
+ connect: async () => {},
18
+ send: async (message) => void sent.push(message),
19
+ expect: async (query) => takeMatch(incoming, query),
20
+ close: async () => {}
21
+ };
22
+ }
23
+ /**
24
+ * Consume the first message matching `query`, or the head of the queue when
25
+ * none match, so a mistyped query still advances instead of deadlocking.
26
+ */
27
+ function takeMatch(queue, query) {
28
+ const index = queue.findIndex((message) => matches(message, query));
29
+ const found = queue.splice(index >= 0 ? index : 0, 1)[0];
30
+ if (!found) throw noMessage();
31
+ return found;
32
+ }
33
+ function matches(message, query) {
34
+ if (query.type !== void 0) return message.type === query.type;
35
+ if (query.where) return matchesWhere(message, query.where);
36
+ return true;
37
+ }
38
+ function matchesWhere(message, where) {
39
+ const record = message;
40
+ return Object.entries(where).every(([key, value]) => equal(record[key], value));
41
+ }
42
+ function equal(a, b) {
43
+ return JSON.stringify(a) === JSON.stringify(b);
44
+ }
45
+ function noMessage() {
46
+ return new VennError({
47
+ code: "VN8091",
48
+ message: "No WebSocket message available to match."
49
+ });
50
+ }
51
+ //#endregion
52
+ //#region src/clients/real-client.ts
53
+ /**
54
+ * The real WebSocket client. Not implemented in this build.
55
+ *
56
+ * It exists so the port has its second implementation and the failure is a named
57
+ * Venn error rather than a missing method.
58
+ *
59
+ * @throws VN8090 from every method.
60
+ */
61
+ function createRealWsClient() {
62
+ return {
63
+ connect: notImplemented,
64
+ send: notImplemented,
65
+ expect: notImplemented,
66
+ close: notImplemented
67
+ };
68
+ }
69
+ function notImplemented() {
70
+ throw new VennError({
71
+ code: "VN8090",
72
+ message: "WebSocket real client not implemented in this build"
73
+ });
74
+ }
75
+ //#endregion
76
+ //#region src/port/ws-client.port.ts
77
+ /**
78
+ * The port descriptor every `ws` verb resolves through `ctx.port(...)`.
79
+ *
80
+ * Declares the `net` capability, so a host that cannot open sockets refuses the
81
+ * binding at load time with a readable diagnostic.
82
+ */
83
+ const WsClientPort = {
84
+ id: "venn.port.ws-client",
85
+ version: 1,
86
+ requires: ["net"],
87
+ methods: [
88
+ "connect",
89
+ "send",
90
+ "expect",
91
+ "close"
92
+ ]
93
+ };
94
+ //#endregion
95
+ //#region src/actions/close.ts
96
+ /** `ws.close`: hang up the connection the port is holding. Takes nothing. */
97
+ const wsClose = defineAction({
98
+ name: "close",
99
+ doc: "Close the WebSocket connection.",
100
+ result: t.void,
101
+ run: (ctx) => ctx.port(WsClientPort).close()
102
+ });
103
+ /**
104
+ * `ws.connect "wss://…" { auth: token }`: open the socket.
105
+ *
106
+ * Returns nothing, because the port owns the connection. That is what lets
107
+ * `ws.send` and `ws.close` reach it without the flow carrying a handle.
108
+ */
109
+ const wsConnect = defineAction({
110
+ name: "connect",
111
+ doc: "Open a WebSocket connection.",
112
+ params: z.object({ auth: z.unknown().optional() }),
113
+ args: [arg("url", t.string, "Where to connect: `ws://` or `wss://`.")],
114
+ result: t.void,
115
+ run: (ctx, input) => ctx.port(WsClientPort).connect({
116
+ url: String(input.args[0] ?? ""),
117
+ auth: input.params.auth
118
+ })
119
+ });
120
+ /**
121
+ * `ws.expect { type: "ack" }` or `ws.expect { where: { … } }`: wait for the next
122
+ * message that matches.
123
+ *
124
+ * The query is the options map, so nothing is positional. The result is typed as
125
+ * `ws.Message`, which is what lets `expect res type "ack"` know its subject.
126
+ */
127
+ const wsExpect = defineAction({
128
+ name: "expect",
129
+ doc: "Wait for the next matching incoming message.",
130
+ params: z.object({
131
+ type: z.string().optional(),
132
+ where: z.record(z.string(), z.unknown()).optional()
133
+ }),
134
+ result: t.ref("ws.Message"),
135
+ run: (ctx, input) => ctx.port(WsClientPort).expect(input.params)
136
+ });
137
+ //#endregion
138
+ //#region src/actions/index.ts
139
+ /** The ws namespace's verbs. Adding one is a new file plus a single line here. */
140
+ const wsActions = [
141
+ wsConnect,
142
+ defineAction({
143
+ name: "send",
144
+ doc: "Send a message over the WebSocket.",
145
+ params: z.object({
146
+ type: z.string().optional(),
147
+ data: z.unknown().optional()
148
+ }),
149
+ result: t.void,
150
+ run: (ctx, input) => ctx.port(WsClientPort).send(input.params)
151
+ }),
152
+ wsExpect,
153
+ wsClose
154
+ ];
155
+ //#endregion
156
+ //#region src/matchers/of-type.ts
157
+ /** `expect res type "ack"`: passes if the message carries exactly that `type`. */
158
+ const ofType = defineMatcher({
159
+ name: "type",
160
+ args: [arg("type", t.string, "The message type expected: `text`, `binary`.")],
161
+ appliesTo: "Message",
162
+ test: ({ subject, args }) => messageType(subject) === String(args[0]),
163
+ message: ({ args }) => `expected the message to have type "${String(args[0])}"`
164
+ });
165
+ function messageType(subject) {
166
+ return subject.type;
167
+ }
168
+ //#endregion
169
+ //#region src/matchers/index.ts
170
+ const wsMatchers = [ofType];
171
+ //#endregion
172
+ //#region src/types/message.ts
173
+ /**
174
+ * Runtime validation for `ws.Message`, registered on the plugin.
175
+ *
176
+ * Pairs with `wsTypeDefs.Message`, which tells the checker the same shape: this
177
+ * one guards a value, that one types an expression.
178
+ */
179
+ const messageSchema = z.object({
180
+ type: z.string().optional(),
181
+ data: z.unknown().optional()
182
+ });
183
+ //#endregion
184
+ //#region src/types/type-defs.ts
185
+ /**
186
+ * The types the plugin publishes to flows, as `ws.Message`.
187
+ *
188
+ * Mirrors `Message` in `message.types.ts` and the Zod schema beside it: the
189
+ * schema guards a value at runtime, this tells the checker what `ws.expect`
190
+ * handed back. Open, because `expect { where: … }` matches on whatever fields a
191
+ * message carries beyond `type` and `data`.
192
+ */
193
+ const wsTypeDefs = {
194
+ /** One message off the socket, as `ws.expect` gives it back. */
195
+ Message: t.record({
196
+ type: t.string,
197
+ data: t.dynamic
198
+ }, {
199
+ optional: ["type", "data"],
200
+ open: true
201
+ }) };
202
+ //#endregion
203
+ //#region src/plugin.ts
204
+ /**
205
+ * The `ws` namespace: `connect`, `send`, `expect`, `close`, the `type` matcher
206
+ * and the `ws.Message` type.
207
+ *
208
+ * Requires the `net` capability, so a host without it refuses the plugin at load
209
+ * time rather than failing mid-flow.
210
+ */
211
+ const wsPlugin = definePlugin({
212
+ name: "venn/ws",
213
+ version: "0.0.0",
214
+ namespace: "ws",
215
+ requires: ["net"],
216
+ actions: wsActions,
217
+ matchers: wsMatchers,
218
+ types: { Message: messageSchema },
219
+ typeDefs: wsTypeDefs
220
+ });
221
+ //#endregion
222
+ export { WsClientPort, createFakeWsClient, createRealWsClient, wsPlugin as default, wsPlugin, messageSchema, wsTypeDefs };
223
+
224
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["params","params"],"sources":["../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/port/ws-client.port.ts","../src/actions/close.ts","../src/actions/connect.ts","../src/actions/expect.ts","../src/actions/send.ts","../src/actions/index.ts","../src/matchers/of-type.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 { FakeWsClient, WsExpectQuery } from \"../port/index.js\";\nimport type { Message } from \"../types/index.js\";\n\n/**\n * The double: no socket, and `expect` resolves at once instead of waiting.\n *\n * @param args.incoming Messages the peer is pretending to have sent, in order.\n * @returns A client whose `sent` array is everything the flow wrote.\n * @throws VN8091 from `expect` once the incoming queue is empty.\n */\nexport function createFakeWsClient(args: { incoming?: Message[] } = {}): FakeWsClient {\n const incoming = [...(args.incoming ?? [])];\n const sent: Message[] = [];\n return {\n sent,\n connect: async () => {},\n send: async (message) => void sent.push(message),\n expect: async (query) => takeMatch(incoming, query),\n close: async () => {},\n };\n}\n\n/**\n * Consume the first message matching `query`, or the head of the queue when\n * none match, so a mistyped query still advances instead of deadlocking.\n */\nfunction takeMatch(queue: Message[], query: WsExpectQuery): Message {\n const index = queue.findIndex((message) => matches(message, query));\n const found = queue.splice(index >= 0 ? index : 0, 1)[0];\n if (!found) throw noMessage();\n return found;\n}\n\nfunction matches(message: Message, query: WsExpectQuery): boolean {\n if (query.type !== undefined) return message.type === query.type;\n if (query.where) return matchesWhere(message, query.where);\n return true;\n}\n\nfunction matchesWhere(message: Message, where: Record<string, unknown>): boolean {\n const record = message as Record<string, unknown>;\n return Object.entries(where).every(([key, value]) => equal(record[key], value));\n}\n\nfunction equal(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction noMessage(): VennError {\n return new VennError({ code: \"VN8091\", message: \"No WebSocket message available to match.\" });\n}\n","import { VennError } from \"@venn-lang/contracts\";\nimport type { WsClient } from \"../port/index.js\";\n\n/**\n * The real WebSocket 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 createRealWsClient(): WsClient {\n return {\n connect: notImplemented,\n send: notImplemented,\n expect: notImplemented,\n close: notImplemented,\n };\n}\n\nfunction notImplemented(): never {\n throw new VennError({\n code: \"VN8090\",\n message: \"WebSocket real client not implemented in this build\",\n });\n}\n","import type { Port } from \"@venn-lang/contracts\";\nimport type { WsClient } from \"./ws-client.types.js\";\n\n/**\n * The port descriptor every `ws` verb resolves through `ctx.port(...)`.\n *\n * Declares the `net` capability, so a host that cannot open sockets refuses the\n * binding at load time with a readable diagnostic.\n */\nexport const WsClientPort: Port<WsClient> = {\n id: \"venn.port.ws-client\",\n version: 1,\n requires: [\"net\"],\n methods: [\"connect\", \"send\", \"expect\", \"close\"],\n};\n","import { type ActionDefinition, defineAction } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { WsClientPort } from \"../port/index.js\";\n\n/** `ws.close`: hang up the connection the port is holding. Takes nothing. */\nexport const wsClose: ActionDefinition = defineAction({\n name: \"close\",\n doc: \"Close the WebSocket connection.\",\n result: t.void,\n run: (ctx) => ctx.port(WsClientPort).close(),\n});\n","import { type ActionDefinition, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { WsClientPort } from \"../port/index.js\";\n\nconst params = z.object({ auth: z.unknown().optional() });\n\n/**\n * `ws.connect \"wss://…\" { auth: token }`: open the socket.\n *\n * Returns nothing, because the port owns the connection. That is what lets\n * `ws.send` and `ws.close` reach it without the flow carrying a handle.\n */\nexport const wsConnect: ActionDefinition = defineAction({\n name: \"connect\",\n doc: \"Open a WebSocket connection.\",\n params,\n args: [arg(\"url\", t.string, \"Where to connect: `ws://` or `wss://`.\")],\n result: t.void,\n run: (ctx, input) =>\n ctx.port(WsClientPort).connect({ url: String(input.args[0] ?? \"\"), auth: input.params.auth }),\n});\n","import { type ActionDefinition, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { WsClientPort } from \"../port/index.js\";\n\nconst params = z.object({\n type: z.string().optional(),\n where: z.record(z.string(), z.unknown()).optional(),\n});\n\n/**\n * `ws.expect { type: \"ack\" }` or `ws.expect { where: { … } }`: wait for the next\n * message that matches.\n *\n * The query is the options map, so nothing is positional. The result is typed as\n * `ws.Message`, which is what lets `expect res type \"ack\"` know its subject.\n */\nexport const wsExpect: ActionDefinition = defineAction({\n name: \"expect\",\n doc: \"Wait for the next matching incoming message.\",\n params,\n result: t.ref(\"ws.Message\"),\n run: (ctx, input) => ctx.port(WsClientPort).expect(input.params),\n});\n","import { type ActionDefinition, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { WsClientPort } from \"../port/index.js\";\n\nconst params = z.object({ type: z.string().optional(), data: z.unknown().optional() });\n\n/**\n * `ws.send { type: \"ping\", data: 1 }`: write one message.\n *\n * Nothing is positional: the options map is the message, and `params` above is\n * what checks its keys.\n */\nexport const wsSend: ActionDefinition = defineAction({\n name: \"send\",\n doc: \"Send a message over the WebSocket.\",\n params,\n result: t.void,\n run: (ctx, input) => ctx.port(WsClientPort).send(input.params),\n});\n","import type { ActionDefinition } from \"@venn-lang/sdk\";\nimport { wsClose } from \"./close.js\";\nimport { wsConnect } from \"./connect.js\";\nimport { wsExpect } from \"./expect.js\";\nimport { wsSend } from \"./send.js\";\n\n/** The ws namespace's verbs. Adding one is a new file plus a single line here. */\nexport const wsActions: ActionDefinition[] = [wsConnect, wsSend, wsExpect, wsClose];\n","import { arg, defineMatcher, type MatcherDefinition } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\n\n/** `expect res type \"ack\"`: passes if the message carries exactly that `type`. */\nexport const ofType: MatcherDefinition = defineMatcher({\n name: \"type\",\n args: [arg(\"type\", t.string, \"The message type expected: `text`, `binary`.\")],\n appliesTo: \"Message\",\n test: ({ subject, args }) => messageType(subject) === String(args[0]),\n message: ({ args }) => `expected the message to have type \"${String(args[0])}\"`,\n});\n\nfunction messageType(subject: unknown): string | undefined {\n return (subject as { type?: string }).type;\n}\n","import type { MatcherDefinition } from \"@venn-lang/sdk\";\nimport { ofType } from \"./of-type.js\";\n\nexport const wsMatchers: MatcherDefinition[] = [ofType];\n","import { type ZodType, z } from \"@venn-lang/sdk\";\n\n/**\n * Runtime validation for `ws.Message`, registered on the plugin.\n *\n * Pairs with `wsTypeDefs.Message`, which tells the checker the same shape: this\n * one guards a value, that one types an expression.\n */\nexport const messageSchema: ZodType = z.object({\n type: z.string().optional(),\n data: z.unknown().optional(),\n});\n","import { type TypeSpec, t } from \"@venn-lang/types\";\n\n/**\n * The types the plugin publishes to flows, as `ws.Message`.\n *\n * Mirrors `Message` in `message.types.ts` and the Zod schema beside it: the\n * schema guards a value at runtime, this tells the checker what `ws.expect`\n * handed back. Open, because `expect { where: … }` matches on whatever fields a\n * message carries beyond `type` and `data`.\n */\nexport const wsTypeDefs: Readonly<Record<string, TypeSpec>> = {\n /** One message off the socket, as `ws.expect` gives it back. */\n Message: t.record(\n { type: t.string, data: t.dynamic },\n { optional: [\"type\", \"data\"], open: true },\n ),\n};\n","import { definePlugin, type PluginDefinition } from \"@venn-lang/sdk\";\nimport { wsActions } from \"./actions/index.js\";\nimport { wsMatchers } from \"./matchers/index.js\";\nimport { messageSchema, wsTypeDefs } from \"./types/index.js\";\n\n/**\n * The `ws` namespace: `connect`, `send`, `expect`, `close`, the `type` matcher\n * and the `ws.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 wsPlugin: PluginDefinition = definePlugin({\n name: \"venn/ws\",\n version: \"0.0.0\",\n namespace: \"ws\",\n requires: [\"net\"],\n actions: wsActions,\n matchers: wsMatchers,\n types: { Message: messageSchema },\n typeDefs: wsTypeDefs,\n});\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@venn-lang/ws",
3
+ "version": "0.1.0",
4
+ "description": "The ws namespace: open a WebSocket, send, wait for a message, close.",
5
+ "keywords": [
6
+ "venn",
7
+ "testing",
8
+ "e2e",
9
+ "ws"
10
+ ],
11
+ "homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-ws#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-ws"
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,11 @@
1
+ import { type ActionDefinition, defineAction } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { WsClientPort } from "../port/index.js";
4
+
5
+ /** `ws.close`: hang up the connection the port is holding. Takes nothing. */
6
+ export const wsClose: ActionDefinition = defineAction({
7
+ name: "close",
8
+ doc: "Close the WebSocket connection.",
9
+ result: t.void,
10
+ run: (ctx) => ctx.port(WsClientPort).close(),
11
+ });
@@ -0,0 +1,21 @@
1
+ import { type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { WsClientPort } from "../port/index.js";
4
+
5
+ const params = z.object({ auth: z.unknown().optional() });
6
+
7
+ /**
8
+ * `ws.connect "wss://…" { auth: token }`: open the socket.
9
+ *
10
+ * Returns nothing, because the port owns the connection. That is what lets
11
+ * `ws.send` and `ws.close` reach it without the flow carrying a handle.
12
+ */
13
+ export const wsConnect: ActionDefinition = defineAction({
14
+ name: "connect",
15
+ doc: "Open a WebSocket connection.",
16
+ params,
17
+ args: [arg("url", t.string, "Where to connect: `ws://` or `wss://`.")],
18
+ result: t.void,
19
+ run: (ctx, input) =>
20
+ ctx.port(WsClientPort).connect({ url: String(input.args[0] ?? ""), auth: input.params.auth }),
21
+ });
@@ -0,0 +1,23 @@
1
+ import { type ActionDefinition, defineAction, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { WsClientPort } from "../port/index.js";
4
+
5
+ const params = z.object({
6
+ type: z.string().optional(),
7
+ where: z.record(z.string(), z.unknown()).optional(),
8
+ });
9
+
10
+ /**
11
+ * `ws.expect { type: "ack" }` or `ws.expect { where: { … } }`: wait for the next
12
+ * message that matches.
13
+ *
14
+ * The query is the options map, so nothing is positional. The result is typed as
15
+ * `ws.Message`, which is what lets `expect res type "ack"` know its subject.
16
+ */
17
+ export const wsExpect: ActionDefinition = defineAction({
18
+ name: "expect",
19
+ doc: "Wait for the next matching incoming message.",
20
+ params,
21
+ result: t.ref("ws.Message"),
22
+ run: (ctx, input) => ctx.port(WsClientPort).expect(input.params),
23
+ });
@@ -0,0 +1,8 @@
1
+ import type { ActionDefinition } from "@venn-lang/sdk";
2
+ import { wsClose } from "./close.js";
3
+ import { wsConnect } from "./connect.js";
4
+ import { wsExpect } from "./expect.js";
5
+ import { wsSend } from "./send.js";
6
+
7
+ /** The ws namespace's verbs. Adding one is a new file plus a single line here. */
8
+ export const wsActions: ActionDefinition[] = [wsConnect, wsSend, wsExpect, wsClose];
@@ -0,0 +1,19 @@
1
+ import { type ActionDefinition, defineAction, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { WsClientPort } from "../port/index.js";
4
+
5
+ const params = z.object({ type: z.string().optional(), data: z.unknown().optional() });
6
+
7
+ /**
8
+ * `ws.send { type: "ping", data: 1 }`: write one message.
9
+ *
10
+ * Nothing is positional: the options map is the message, and `params` above is
11
+ * what checks its keys.
12
+ */
13
+ export const wsSend: ActionDefinition = defineAction({
14
+ name: "send",
15
+ doc: "Send a message over the WebSocket.",
16
+ params,
17
+ result: t.void,
18
+ run: (ctx, input) => ctx.port(WsClientPort).send(input.params),
19
+ });
@@ -0,0 +1,52 @@
1
+ import { VennError } from "@venn-lang/contracts";
2
+ import type { FakeWsClient, WsExpectQuery } from "../port/index.js";
3
+ import type { Message } from "../types/index.js";
4
+
5
+ /**
6
+ * The double: no socket, and `expect` resolves at once instead of waiting.
7
+ *
8
+ * @param args.incoming Messages the peer is pretending to have sent, in order.
9
+ * @returns A client whose `sent` array is everything the flow wrote.
10
+ * @throws VN8091 from `expect` once the incoming queue is empty.
11
+ */
12
+ export function createFakeWsClient(args: { incoming?: Message[] } = {}): FakeWsClient {
13
+ const incoming = [...(args.incoming ?? [])];
14
+ const sent: Message[] = [];
15
+ return {
16
+ sent,
17
+ connect: async () => {},
18
+ send: async (message) => void sent.push(message),
19
+ expect: async (query) => takeMatch(incoming, query),
20
+ close: async () => {},
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Consume the first message matching `query`, or the head of the queue when
26
+ * none match, so a mistyped query still advances instead of deadlocking.
27
+ */
28
+ function takeMatch(queue: Message[], query: WsExpectQuery): Message {
29
+ const index = queue.findIndex((message) => matches(message, query));
30
+ const found = queue.splice(index >= 0 ? index : 0, 1)[0];
31
+ if (!found) throw noMessage();
32
+ return found;
33
+ }
34
+
35
+ function matches(message: Message, query: WsExpectQuery): boolean {
36
+ if (query.type !== undefined) return message.type === query.type;
37
+ if (query.where) return matchesWhere(message, query.where);
38
+ return true;
39
+ }
40
+
41
+ function matchesWhere(message: Message, where: Record<string, unknown>): boolean {
42
+ const record = message as Record<string, unknown>;
43
+ return Object.entries(where).every(([key, value]) => equal(record[key], value));
44
+ }
45
+
46
+ function equal(a: unknown, b: unknown): boolean {
47
+ return JSON.stringify(a) === JSON.stringify(b);
48
+ }
49
+
50
+ function noMessage(): VennError {
51
+ return new VennError({ code: "VN8091", message: "No WebSocket message available to match." });
52
+ }
@@ -0,0 +1,2 @@
1
+ export { createFakeWsClient } from "./fake-client.js";
2
+ export { createRealWsClient } from "./real-client.js";
@@ -0,0 +1,26 @@
1
+ import { VennError } from "@venn-lang/contracts";
2
+ import type { WsClient } from "../port/index.js";
3
+
4
+ /**
5
+ * The real WebSocket 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 createRealWsClient(): WsClient {
13
+ return {
14
+ connect: notImplemented,
15
+ send: notImplemented,
16
+ expect: notImplemented,
17
+ close: notImplemented,
18
+ };
19
+ }
20
+
21
+ function notImplemented(): never {
22
+ throw new VennError({
23
+ code: "VN8090",
24
+ message: "WebSocket real client not implemented in this build",
25
+ });
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `ws` plugin: `connect`, `send`, `expect` and `close` over one socket.
3
+ *
4
+ * The socket lives behind the `WsClient` port, so a flow never carries a handle
5
+ * and the same script runs against a real endpoint or the in-memory double.
6
+ */
7
+
8
+ export * from "./clients/index.js";
9
+ export { wsPlugin, wsPlugin as default } from "./plugin.js";
10
+ export * from "./port/index.js";
11
+ export * from "./types/index.js";
@@ -0,0 +1,4 @@
1
+ import type { MatcherDefinition } from "@venn-lang/sdk";
2
+ import { ofType } from "./of-type.js";
3
+
4
+ export const wsMatchers: MatcherDefinition[] = [ofType];
@@ -0,0 +1,15 @@
1
+ import { arg, defineMatcher, type MatcherDefinition } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+
4
+ /** `expect res type "ack"`: passes if the message carries exactly that `type`. */
5
+ export const ofType: MatcherDefinition = defineMatcher({
6
+ name: "type",
7
+ args: [arg("type", t.string, "The message type expected: `text`, `binary`.")],
8
+ appliesTo: "Message",
9
+ test: ({ subject, args }) => messageType(subject) === String(args[0]),
10
+ message: ({ args }) => `expected the message to have type "${String(args[0])}"`,
11
+ });
12
+
13
+ function messageType(subject: unknown): string | undefined {
14
+ return (subject as { type?: string }).type;
15
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
2
+ import { wsActions } from "./actions/index.js";
3
+ import { wsMatchers } from "./matchers/index.js";
4
+ import { messageSchema, wsTypeDefs } from "./types/index.js";
5
+
6
+ /**
7
+ * The `ws` namespace: `connect`, `send`, `expect`, `close`, the `type` matcher
8
+ * and the `ws.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 wsPlugin: PluginDefinition = definePlugin({
14
+ name: "venn/ws",
15
+ version: "0.0.0",
16
+ namespace: "ws",
17
+ requires: ["net"],
18
+ actions: wsActions,
19
+ matchers: wsMatchers,
20
+ types: { Message: messageSchema },
21
+ typeDefs: wsTypeDefs,
22
+ });
@@ -0,0 +1,2 @@
1
+ export { WsClientPort } from "./ws-client.port.js";
2
+ export type { FakeWsClient, WsClient, WsConnectArgs, WsExpectQuery } from "./ws-client.types.js";
@@ -0,0 +1,15 @@
1
+ import type { Port } from "@venn-lang/contracts";
2
+ import type { WsClient } from "./ws-client.types.js";
3
+
4
+ /**
5
+ * The port descriptor every `ws` verb resolves through `ctx.port(...)`.
6
+ *
7
+ * Declares the `net` capability, so a host that cannot open sockets refuses the
8
+ * binding at load time with a readable diagnostic.
9
+ */
10
+ export const WsClientPort: Port<WsClient> = {
11
+ id: "venn.port.ws-client",
12
+ version: 1,
13
+ requires: ["net"],
14
+ methods: ["connect", "send", "expect", "close"],
15
+ };
@@ -0,0 +1,37 @@
1
+ import type { Message } from "../types/index.js";
2
+
3
+ /** What `ws.connect` passes on: the URL it was given, and any `auth` from the opts map. */
4
+ export interface WsConnectArgs {
5
+ url: string;
6
+ auth?: unknown;
7
+ }
8
+
9
+ /**
10
+ * Which incoming message `ws.expect` is waiting for: one with this `type`, or
11
+ * one whose fields match every entry in `where`. Both empty means the next one.
12
+ */
13
+ export interface WsExpectQuery {
14
+ type?: string;
15
+ where?: Record<string, unknown>;
16
+ }
17
+
18
+ /**
19
+ * One WebSocket connection: open it, write to it, wait on it, close it.
20
+ *
21
+ * The port holds the socket, which is why no method takes a handle: a flow says
22
+ * `ws.send { … }` and the implementation knows which connection it means.
23
+ *
24
+ * Two implementations: `createRealWsClient` and `createFakeWsClient`.
25
+ */
26
+ export interface WsClient {
27
+ connect(args: WsConnectArgs): Promise<void>;
28
+ send(message: Message): Promise<void>;
29
+ expect(query: WsExpectQuery): Promise<Message>;
30
+ close(): Promise<void>;
31
+ }
32
+
33
+ /** A `WsClient` that also lets a test read back what the flow sent. */
34
+ export interface FakeWsClient extends WsClient {
35
+ /** Every message handed to `send`, in order. */
36
+ readonly sent: readonly Message[];
37
+ }
@@ -0,0 +1,3 @@
1
+ export { messageSchema } from "./message.js";
2
+ export type { Message } from "./message.types.js";
3
+ export { wsTypeDefs } from "./type-defs.js";
@@ -0,0 +1,12 @@
1
+ import { type ZodType, z } from "@venn-lang/sdk";
2
+
3
+ /**
4
+ * Runtime validation for `ws.Message`, registered on the plugin.
5
+ *
6
+ * Pairs with `wsTypeDefs.Message`, which tells the checker the same shape: this
7
+ * one guards a value, that one types an expression.
8
+ */
9
+ export const messageSchema: ZodType = z.object({
10
+ type: z.string().optional(),
11
+ data: z.unknown().optional(),
12
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * One message on the socket, and what `ws.expect` hands back.
3
+ *
4
+ * Both fields are optional because a peer is free to send whatever it likes;
5
+ * `where` queries match on fields beyond these two.
6
+ */
7
+ export interface Message {
8
+ type?: string;
9
+ data?: unknown;
10
+ }
@@ -0,0 +1,17 @@
1
+ import { type TypeSpec, t } from "@venn-lang/types";
2
+
3
+ /**
4
+ * The types the plugin publishes to flows, as `ws.Message`.
5
+ *
6
+ * Mirrors `Message` in `message.types.ts` and the Zod schema beside it: the
7
+ * schema guards a value at runtime, this tells the checker what `ws.expect`
8
+ * handed back. Open, because `expect { where: … }` matches on whatever fields a
9
+ * message carries beyond `type` and `data`.
10
+ */
11
+ export const wsTypeDefs: Readonly<Record<string, TypeSpec>> = {
12
+ /** One message off the socket, as `ws.expect` gives it back. */
13
+ Message: t.record(
14
+ { type: t.string, data: t.dynamic },
15
+ { optional: ["type", "data"], open: true },
16
+ ),
17
+ };