@lambdot/websocket 0.1.0 → 0.2.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +145 -0
  3. package/package.json +18 -13
  4. package/src/index.ts +99 -155
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7), [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7)]:
8
+ - @lambdot/core@0.2.0
9
+
10
+ ## 0.1.1
11
+
12
+ ### Patch Changes
13
+
14
+ - [#10](https://github.com/Embers-of-the-Fire/lambdot/pull/10) [`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Switch inter-package dependency pins from exact versions to `workspace:*` so workspace members always resolve against local sources during development; pnpm rewrites the protocol to exact versions at pack/publish time.
15
+ - Updated dependencies [[`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355)]:
16
+ - @lambdot/core@0.1.1
17
+
3
18
  ## [0.1.0](https://github.com/Embers-of-the-Fire/lambdot/compare/websocket-v0.0.1...websocket-v0.1.0) (2026-08-29)
4
19
 
5
20
 
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # @lambdot/websocket
2
+
3
+ The generic websocket transport. It owns the socket lifecycle — connect,
4
+ fan out incoming text frames, close on unload — and nothing else: no message
5
+ shapes, no addresses, no content contract. Everything chat-specific is
6
+ deferred to a `WsSpec` supplied by consumers, which makes this a core
7
+ behavior (like `console`), not a chat platform. Protocol packages
8
+ (`@lambdot/protocol-qq`, …) ride this transport by supplying their own
9
+ specs.
10
+
11
+ The transport is a **service plugin, not a stream**: `wsTransport(name)`
12
+ emits the live `WsConnection` as its namespace value under `name`. The
13
+ `wsInput(name, spec)` / `wsOutput(name, spec)` factories build a platform's
14
+ two halves from the spec, declaring the connection in their input — wiring
15
+ them before the transport is a compile error, because the `mapping`
16
+ parameter is typed as the namespaces visible so far. The socket opens when
17
+ the transport activates and closes when the composition stops.
18
+
19
+ The name is a parameter, so instances multiply: distinct names compose side
20
+ by side (`wsPlatform("wsecho-a", spec)` next to `wsPlatform("wsecho-b", spec)`),
21
+ and several websocket platforms share one kernel, each platform's input and
22
+ output mapping its own transport's connection.
23
+
24
+ ## Usage
25
+
26
+ Prefer the bundle. `wsPlatform(name, spec)` builds one websocket platform as
27
+ a triple of plugins — they stay separate so feature plugins can be wired
28
+ between the input (whose message stream is `use`d) and the output (which is
29
+ terminal and always wired last). The transport and output are usually
30
+ `bind`ed, keeping them internal to the chain:
31
+
32
+ ```ts
33
+ import type { Message, Stream } from "@lambdot/core";
34
+ import { createKernel, definePlugin, mapStream } from "@lambdot/core";
35
+ import { wsPlatform } from "@lambdot/websocket";
36
+
37
+ import { echoSpec, type WsEchoAddress } from "./echo-spec.ts";
38
+
39
+ const reply = definePlugin({
40
+ name: "reply",
41
+ apply(input: { wsecho: Stream<Message<string, WsEchoAddress>> }) {
42
+ return mapStream(input.wsecho, (event) => ({
43
+ address: event.address,
44
+ content: `echo: ${event.payload}`,
45
+ }));
46
+ },
47
+ });
48
+
49
+ const wsecho = wsPlatform("wsecho", echoSpec);
50
+
51
+ const kernel = createKernel()
52
+ .bind(wsecho.transport, { option: { url } })
53
+ .use(wsecho.input, { mapping: (ctx) => ({ connection: ctx["wsecho/transport"] }) })
54
+ // identity wiring: reply's input keys already match the visible ctx
55
+ .use(reply)
56
+ .bind(wsecho.output, {
57
+ mapping: (ctx) => ({ connection: ctx["wsecho/transport"], commands: ctx.reply }),
58
+ });
59
+ ```
60
+
61
+ Reach for the individual `wsTransport` / `wsInput` / `wsOutput` factories
62
+ when a kernel hosts several tagged websocket platforms and the triples must
63
+ interleave with other plugins — the factories the bundle wraps stay exported
64
+ for exactly that. Each output filters the shared command stream back down to
65
+ its own platform tag, so `address.platform` routes every reply out the
66
+ socket it arrived on:
67
+
68
+ ```ts
69
+ const wsechoA = wsPlatform("wsecho-a", echoSpec("a"));
70
+ const wsechoB = wsPlatform("wsecho-b", echoSpec("b"));
71
+
72
+ const kernel = createKernel()
73
+ .bind(wsechoA.transport, { option: { url: urlA } })
74
+ .use(wsechoA.input, { mapping: (ctx) => ({ connection: ctx["wsecho-a/transport"] }) })
75
+ .bind(wsechoB.transport, { option: { url: urlB } })
76
+ .use(wsechoB.input, { mapping: (ctx) => ({ connection: ctx["wsecho-b/transport"] }) })
77
+ .use(reply)
78
+ .bind(wsechoA.output, {
79
+ mapping: (ctx) => ({
80
+ connection: ctx["wsecho-a/transport"],
81
+ commands: filterStream(
82
+ ctx.reply,
83
+ (cmd): cmd is Command<AddressA, string> => cmd.address.platform === "wsecho-a",
84
+ ),
85
+ }),
86
+ })
87
+ .bind(wsechoB.output, {/* ...same for "wsecho-b"... */});
88
+ ```
89
+
90
+ A concrete platform supplies one `WsSpec` — platform tag, address shape,
91
+ frame codec — and nothing else:
92
+
93
+ ```ts
94
+ import type { Address } from "@lambdot/core";
95
+ import type { WsSpec } from "@lambdot/websocket";
96
+
97
+ export type WsEchoAddress = Address<"wsecho">;
98
+
99
+ export const echoSpec: WsSpec<"wsecho", WsEchoAddress, string, string> = {
100
+ platform: "wsecho",
101
+ decode: (data) => ({ payload: data, address: { platform: "wsecho" } }),
102
+ encode: (content) => content,
103
+ };
104
+ ```
105
+
106
+ `decode` may return `null` to ignore a frame. Adding a second platform
107
+ costs one spec object, ~5 lines; the transport, factories, and feature
108
+ plugins don't change.
109
+
110
+ ## API
111
+
112
+ - `wsPlatform(name, spec)` — bundle a transport and the input/output halves
113
+ built from `spec` into a `WsPlatform` triple (`{ transport, input, output }`).
114
+ The transport is named `${name}/transport`, the input `name`, the output
115
+ `${name}/output`.
116
+ - `wsTransport(name)` — the general half: connection lifecycle only. Config
117
+ is `{ url }` (passed via `option`); emits the live `WsConnection` under
118
+ the name.
119
+ - `wsInput(name, spec)` — subscribes to the connection, decodes each frame,
120
+ and emits a shared `Stream<Message<TPayload, TAddress>>` of the decoded
121
+ messages.
122
+ - `wsOutput(name, spec)` — consumes a `Stream<Command<TAddress, TContent>>`
123
+ and sends each command's encoded content through the connection. Terminal:
124
+ wire it last, after the features whose reply streams it consumes.
125
+ - `WsSpec<TPlatform, TAddress, TPayload, TContent>` — the deferred behavior
126
+ slot: `platform`, plus the `decode` / `encode` frame codec.
127
+ - `WsConnection` — the shared transport service: `url`, `send(data)`,
128
+ `onMessage(listener)` (text frames only; binary frames are dropped).
129
+ - `WsTransportConfig` — `{ readonly url: string }`.
130
+
131
+ ## Examples
132
+
133
+ - [../../../examples/websocket-bot](../../../examples/websocket-bot) — one platform
134
+ end to end: a raw driver round-trips through server, transport, input,
135
+ reply feature, and output.
136
+ - [../../../examples/dual-websocket-bot](../../../examples/dual-websocket-bot) — two
137
+ tagged platforms sharing one kernel under distinct names, each output
138
+ filtering the shared command stream by `address.platform`.
139
+
140
+ Protocol packages under `packages/protocol/` ride this transport, supplying
141
+ their wire protocol's address type and frame codec as a `WsSpec`.
142
+
143
+ ## License
144
+
145
+ Dual-licensed under [Apache-2.0](../../../LICENSE-APACHE) and [MIT](../../../LICENSE-MIT).
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
- "name": "@lambdot/websocket",
3
- "version": "0.1.0",
4
- "type": "module",
5
- "exports": {
6
- ".": "./src/index.ts"
7
- },
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "dependencies": {
12
- "@lambdot/core": "workspace:*"
13
- }
14
- }
2
+ "name": "@lambdot/websocket",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/Embers-of-the-Fire/lambdot",
7
+ "directory": "packages/core/websocket"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "@lambdot/core": "0.2.0"
18
+ }
19
+ }
package/src/index.ts CHANGED
@@ -1,23 +1,17 @@
1
- import type {
2
- Address,
3
- Disposer,
4
- EventDef,
5
- FeaturePlugin,
6
- InputPlugin,
7
- OutputPlugin,
8
- } from "@lambdot/core";
1
+ import type { Address, Command, Message, Plugin, Stream } from "@lambdot/core";
2
+ import { channel, definePlugin, message, pumpStream, shareStream } from "@lambdot/core";
9
3
 
10
4
  /**
11
- * The shared transport service, provided as a typed capability. Owns the
12
- * socket; platform plugins consume it — the transport itself knows nothing
13
- * about platforms, addresses, or event kinds.
5
+ * The shared transport service, emitted as a plugin's output value. Owns the
6
+ * socket; platform plugins consume it through their input — the transport
7
+ * itself knows nothing about platforms, addresses, or message shapes.
14
8
  */
15
9
  export interface WsConnection {
16
10
  readonly url: string;
17
11
  /** Send a text frame. */
18
12
  send(data: string): void;
19
- /** Subscribe to incoming text frames. Returns an unsubscribe disposer. */
20
- onMessage(listener: (data: string) => void): Disposer;
13
+ /** Subscribe to incoming text frames. The listener remains active for the connection lifetime. */
14
+ onMessage(listener: (data: string) => void): void;
21
15
  }
22
16
 
23
17
  export interface WsTransportConfig {
@@ -25,28 +19,16 @@ export interface WsTransportConfig {
25
19
  }
26
20
 
27
21
  /**
28
- * The typed capability contract shared by a transport and its consumers,
29
- * parameterized by capability name: the transport declares it as
30
- * `TProvides`, the platform factories as `TInjects`. Distinct names fold
31
- * side by side (`WsCapability<"ws-discord"> & WsCapability<"ws-qq">`), so
32
- * several websocket platforms can share one kernel — the kernel rejects a
33
- * duplicate capability name at runtime, and the type fold makes each
34
- * consumer read back its own connection with no casts.
22
+ * The general half: connection lifecycle only. Emits the live connection as
23
+ * its output value; platform plugins declare it as their input. The socket
24
+ * opens at activation and closes when the composition stops.
35
25
  */
36
- export type WsCapability<TCap extends string> = { readonly [K in TCap]: WsConnection };
37
-
38
- /**
39
- * The general half: connection lifecycle only. Provides the live connection
40
- * as a typed value under the given capability name (folded into the kernel
41
- * context as `ctx[capability]`); platform plugins activate once it is
42
- * available via `inject: [capability]`.
43
- */
44
- export function wsTransport<TCap extends string>(
45
- capability: TCap,
46
- ): FeaturePlugin<{}, {}, undefined, WsTransportConfig, `ws-transport:${TCap}`, WsCapability<TCap>> {
47
- return {
48
- name: `ws-transport:${capability}`,
49
- async apply(ctx, config) {
26
+ export function wsTransport<const TName extends string>(
27
+ name: TName,
28
+ ): Plugin<void, WsConnection, WsTransportConfig, TName> {
29
+ return definePlugin({
30
+ name,
31
+ async apply(_input, scope, config) {
50
32
  const socket = new WebSocket(config.url);
51
33
  await new Promise<void>((resolve, reject) => {
52
34
  socket.addEventListener("open", () => resolve(), { once: true });
@@ -58,199 +40,161 @@ export function wsTransport<TCap extends string>(
58
40
  },
59
41
  );
60
42
  });
61
-
62
- const listeners = new Set<(data: string) => void>();
63
- socket.addEventListener("message", (event) => {
64
- if (typeof event.data !== "string") return;
65
- for (const listener of listeners) listener(event.data);
43
+ scope.onDispose(() => {
44
+ socket.close();
66
45
  });
67
46
 
68
- const connection: WsConnection = {
47
+ return {
69
48
  url: config.url,
70
49
  send: (data) => socket.send(data),
71
50
  onMessage(listener) {
72
- listeners.add(listener);
73
- return () => {
74
- listeners.delete(listener);
75
- };
51
+ socket.addEventListener("message", (event) => {
52
+ if (typeof event.data === "string") listener(event.data);
53
+ });
76
54
  },
77
55
  };
78
-
79
- // The kernel's `provide` keeps its value parameter behind a
80
- // conditional type that stays deferred for a generic capability
81
- // name; `WsCapability<TCap>` already ties this name to
82
- // `WsConnection`, so pin the call down here.
83
- const unprovide = (ctx.provide as (name: TCap, value: WsConnection) => Disposer).call(
84
- ctx,
85
- capability,
86
- connection,
87
- );
88
- return () => {
89
- socket.close();
90
- void unprovide();
91
- };
92
56
  },
93
- };
57
+ });
94
58
  }
95
59
 
96
60
  /**
97
61
  * The deferred behavior slot: everything the generic transport cannot know —
98
- * the platform tag, the event kind, and the frame codec. A concrete platform
99
- * (discord, qq, ...) supplies one of these to the factories below.
62
+ * the platform tag, the address shape, and the frame codec. A concrete
63
+ * platform (discord, qq, ...) supplies one of these to the factories below.
100
64
  */
101
65
  export interface WsSpec<
102
66
  TPlatform extends string,
103
67
  TAddress extends Address<TPlatform>,
104
68
  TPayload,
105
69
  TContent,
106
- TKind extends string,
107
70
  > {
108
71
  readonly platform: TPlatform;
109
- readonly kind: TKind;
110
- /** Decode an incoming frame into an event, or null to ignore the frame. */
72
+ /** Decode an incoming frame into a message, or null to ignore the frame. */
111
73
  decode(data: string): { payload: TPayload; address: TAddress } | null;
112
74
  /** Encode outgoing content into a text frame. */
113
75
  encode(content: TContent, address: TAddress): string;
114
76
  }
115
77
 
116
78
  /**
117
- * Build the input half of a websocket platform from its spec, consuming the
118
- * connection provided under `capability` by a `wsTransport`. Event kinds
119
- * flow through the generic fold, so registration-order gating still applies
120
- * to the concrete instantiation.
79
+ * The input half of a websocket platform: consumes the connection and emits
80
+ * the stream of decoded messages. Wire the connection with a mapping from
81
+ * the transport's namespace.
121
82
  */
122
83
  export function wsInput<
123
- TCap extends string,
84
+ const TName extends string,
124
85
  TPlatform extends string,
125
86
  TAddress extends Address<TPlatform>,
126
87
  TPayload,
127
88
  TContent,
128
- TKind extends string,
129
89
  >(
130
- capability: TCap,
131
- spec: WsSpec<TPlatform, TAddress, TPayload, TContent, TKind>,
132
- ): InputPlugin<
133
- { [K in TKind]: EventDef<TPayload, TAddress> },
134
- void,
135
- `${TPlatform}-input`,
136
- {},
137
- WsCapability<TCap>
138
- > {
139
- return {
140
- role: "input",
141
- name: `${spec.platform}-input`,
142
- inject: [capability],
143
- apply(ctx) {
144
- return ctx[capability].onMessage((data) => {
90
+ name: TName,
91
+ spec: WsSpec<TPlatform, TAddress, TPayload, TContent>,
92
+ ): Plugin<{ connection: WsConnection }, Stream<Message<TPayload, TAddress>>, void, TName> {
93
+ return definePlugin({
94
+ name,
95
+ apply(input, scope) {
96
+ const messages = channel<Message<TPayload, TAddress>>();
97
+ input.connection.onMessage((data) => {
145
98
  const decoded = spec.decode(data);
146
- if (decoded) void ctx.ingest(spec.kind, decoded.payload, decoded.address);
99
+ if (decoded) messages.push(message(decoded.payload, decoded.address));
147
100
  });
101
+ scope.onDispose(() => {
102
+ messages.close();
103
+ });
104
+ // Shared: several consumers may subscribe to the message stream.
105
+ return shareStream(messages.stream);
148
106
  },
149
- };
107
+ });
150
108
  }
151
109
 
152
- /** Build the output half of a websocket platform from its spec. */
110
+ /**
111
+ * The output half of a websocket platform: consumes a command stream and
112
+ * sends each command's encoded content through the connection. Terminal —
113
+ * wire it last, after the features whose reply streams it consumes.
114
+ */
153
115
  export function wsOutput<
154
- TCap extends string,
116
+ const TName extends string,
155
117
  TPlatform extends string,
156
118
  TAddress extends Address<TPlatform>,
157
119
  TPayload,
158
120
  TContent,
159
- TKind extends string,
160
121
  >(
161
- capability: TCap,
162
- spec: WsSpec<TPlatform, TAddress, TPayload, TContent, TKind>,
163
- ): OutputPlugin<
164
- TPlatform,
165
- TAddress,
166
- TContent,
122
+ name: TName,
123
+ spec: WsSpec<TPlatform, TAddress, TPayload, TContent>,
124
+ ): Plugin<
125
+ { connection: WsConnection; commands: Stream<Command<TAddress, TContent>> },
126
+ void,
167
127
  void,
168
- `${TPlatform}-output`,
169
- {},
170
- WsCapability<TCap>
128
+ TName
171
129
  > {
172
- let connection: WsConnection | undefined;
173
- return {
174
- role: "output",
175
- name: `${spec.platform}-output`,
176
- platform: spec.platform,
177
- inject: [capability],
178
- send(to, content) {
179
- if (!connection) throw new Error(`output "${spec.platform}" is not active`);
180
- connection.send(spec.encode(content, to));
181
- },
182
- apply(ctx) {
183
- connection = ctx[capability];
184
- return () => {
185
- connection = undefined;
186
- };
130
+ return definePlugin({
131
+ name,
132
+ apply(input, scope) {
133
+ const { connection } = input;
134
+ scope.onDispose(
135
+ pumpStream(
136
+ input.commands,
137
+ (cmd) => connection.send(spec.encode(cmd.content, cmd.address)),
138
+ (error) => scope.onError(error),
139
+ ),
140
+ );
187
141
  },
188
- };
142
+ });
189
143
  }
190
144
 
191
145
  /**
192
- * One websocket platform, bundled: the transport that owns the socket under
193
- * `capability`, plus the input/output halves built from `spec`. The triple
194
- * stays separate (rather than one fused plugin) so the type fold can keep
195
- * enforcing registration order — transport before the halves that inject it.
196
- *
146
+ * One websocket platform, bundled as three leaves. The transport and output
147
+ * are usually `bind`ed (internal wiring); the input's message stream is
148
+ * `use`d (exposed to features). The output is terminal, so it is always
149
+ * wired last:
150
+
197
151
  * ```ts
198
- * const discord = wsPlatform("ws-discord", discordSpec);
152
+ * const wsecho = wsPlatform("wsecho", echoSpec);
199
153
  * createKernel()
200
- * .use(discord.transport, { url })
201
- * .use(discord.input)
202
- * .use(discord.output);
154
+ * .bind(wsecho.transport, { option: { url } })
155
+ * .use(wsecho.input, { mapping: (ctx) => ({ connection: ctx["wsecho/transport"] }) })
156
+ * .use(reply)
157
+ * .bind(wsecho.output, {
158
+ * mapping: (ctx) => ({ connection: ctx["wsecho/transport"], commands: ctx.reply }),
159
+ * });
203
160
  * ```
204
161
  */
205
162
  export interface WsPlatform<
206
- TCap extends string,
163
+ TName extends string,
207
164
  TPlatform extends string,
208
165
  TAddress extends Address<TPlatform>,
209
166
  TPayload,
210
167
  TContent,
211
- TKind extends string,
212
168
  > {
213
- readonly transport: FeaturePlugin<
214
- {},
215
- {},
216
- undefined,
217
- WsTransportConfig,
218
- `ws-transport:${TCap}`,
219
- WsCapability<TCap>
220
- >;
221
- readonly input: InputPlugin<
222
- { [K in TKind]: EventDef<TPayload, TAddress> },
169
+ readonly transport: Plugin<void, WsConnection, WsTransportConfig, `${TName}/transport`>;
170
+ readonly input: Plugin<
171
+ { connection: WsConnection },
172
+ Stream<Message<TPayload, TAddress>>,
223
173
  void,
224
- `${TPlatform}-input`,
225
- {},
226
- WsCapability<TCap>
174
+ TName
227
175
  >;
228
- readonly output: OutputPlugin<
229
- TPlatform,
230
- TAddress,
231
- TContent,
176
+ readonly output: Plugin<
177
+ { connection: WsConnection; commands: Stream<Command<TAddress, TContent>> },
178
+ void,
232
179
  void,
233
- `${TPlatform}-output`,
234
- {},
235
- WsCapability<TCap>
180
+ `${TName}/output`
236
181
  >;
237
182
  }
238
183
 
239
- /** Build a whole websocket platform (transport + input + output) from a capability name and a spec. */
184
+ /** Build a whole websocket platform (transport + input + output) from a name and a spec. */
240
185
  export function wsPlatform<
241
- TCap extends string,
186
+ const TName extends string,
242
187
  TPlatform extends string,
243
188
  TAddress extends Address<TPlatform>,
244
189
  TPayload,
245
190
  TContent,
246
- TKind extends string,
247
191
  >(
248
- capability: TCap,
249
- spec: WsSpec<TPlatform, TAddress, TPayload, TContent, TKind>,
250
- ): WsPlatform<TCap, TPlatform, TAddress, TPayload, TContent, TKind> {
192
+ name: TName,
193
+ spec: WsSpec<TPlatform, TAddress, TPayload, TContent>,
194
+ ): WsPlatform<TName, TPlatform, TAddress, TPayload, TContent> {
251
195
  return {
252
- transport: wsTransport(capability),
253
- input: wsInput(capability, spec),
254
- output: wsOutput(capability, spec),
196
+ transport: wsTransport(`${name}/transport`),
197
+ input: wsInput(name, spec),
198
+ output: wsOutput(`${name}/output`, spec),
255
199
  };
256
200
  }