@lambdot/websocket 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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0](https://github.com/Embers-of-the-Fire/lambdot/compare/websocket-v0.0.1...websocket-v0.1.0) (2026-08-29)
4
+
5
+
6
+ ### Miscellaneous Chores
7
+
8
+ * **websocket:** Synchronize lambdot versions
package/package.json ADDED
@@ -0,0 +1,14 @@
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,256 @@
1
+ import type {
2
+ Address,
3
+ Disposer,
4
+ EventDef,
5
+ FeaturePlugin,
6
+ InputPlugin,
7
+ OutputPlugin,
8
+ } from "@lambdot/core";
9
+
10
+ /**
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.
14
+ */
15
+ export interface WsConnection {
16
+ readonly url: string;
17
+ /** Send a text frame. */
18
+ send(data: string): void;
19
+ /** Subscribe to incoming text frames. Returns an unsubscribe disposer. */
20
+ onMessage(listener: (data: string) => void): Disposer;
21
+ }
22
+
23
+ export interface WsTransportConfig {
24
+ readonly url: string;
25
+ }
26
+
27
+ /**
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.
35
+ */
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) {
50
+ const socket = new WebSocket(config.url);
51
+ await new Promise<void>((resolve, reject) => {
52
+ socket.addEventListener("open", () => resolve(), { once: true });
53
+ socket.addEventListener(
54
+ "error",
55
+ () => reject(new Error("websocket failed to connect")),
56
+ {
57
+ once: true,
58
+ },
59
+ );
60
+ });
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);
66
+ });
67
+
68
+ const connection: WsConnection = {
69
+ url: config.url,
70
+ send: (data) => socket.send(data),
71
+ onMessage(listener) {
72
+ listeners.add(listener);
73
+ return () => {
74
+ listeners.delete(listener);
75
+ };
76
+ },
77
+ };
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
+ },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * 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.
100
+ */
101
+ export interface WsSpec<
102
+ TPlatform extends string,
103
+ TAddress extends Address<TPlatform>,
104
+ TPayload,
105
+ TContent,
106
+ TKind extends string,
107
+ > {
108
+ readonly platform: TPlatform;
109
+ readonly kind: TKind;
110
+ /** Decode an incoming frame into an event, or null to ignore the frame. */
111
+ decode(data: string): { payload: TPayload; address: TAddress } | null;
112
+ /** Encode outgoing content into a text frame. */
113
+ encode(content: TContent, address: TAddress): string;
114
+ }
115
+
116
+ /**
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.
121
+ */
122
+ export function wsInput<
123
+ TCap extends string,
124
+ TPlatform extends string,
125
+ TAddress extends Address<TPlatform>,
126
+ TPayload,
127
+ TContent,
128
+ TKind extends string,
129
+ >(
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) => {
145
+ const decoded = spec.decode(data);
146
+ if (decoded) void ctx.ingest(spec.kind, decoded.payload, decoded.address);
147
+ });
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Build the output half of a websocket platform from its spec. */
153
+ export function wsOutput<
154
+ TCap extends string,
155
+ TPlatform extends string,
156
+ TAddress extends Address<TPlatform>,
157
+ TPayload,
158
+ TContent,
159
+ TKind extends string,
160
+ >(
161
+ capability: TCap,
162
+ spec: WsSpec<TPlatform, TAddress, TPayload, TContent, TKind>,
163
+ ): OutputPlugin<
164
+ TPlatform,
165
+ TAddress,
166
+ TContent,
167
+ void,
168
+ `${TPlatform}-output`,
169
+ {},
170
+ WsCapability<TCap>
171
+ > {
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
+ };
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
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
+ *
197
+ * ```ts
198
+ * const discord = wsPlatform("ws-discord", discordSpec);
199
+ * createKernel()
200
+ * .use(discord.transport, { url })
201
+ * .use(discord.input)
202
+ * .use(discord.output);
203
+ * ```
204
+ */
205
+ export interface WsPlatform<
206
+ TCap extends string,
207
+ TPlatform extends string,
208
+ TAddress extends Address<TPlatform>,
209
+ TPayload,
210
+ TContent,
211
+ TKind extends string,
212
+ > {
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> },
223
+ void,
224
+ `${TPlatform}-input`,
225
+ {},
226
+ WsCapability<TCap>
227
+ >;
228
+ readonly output: OutputPlugin<
229
+ TPlatform,
230
+ TAddress,
231
+ TContent,
232
+ void,
233
+ `${TPlatform}-output`,
234
+ {},
235
+ WsCapability<TCap>
236
+ >;
237
+ }
238
+
239
+ /** Build a whole websocket platform (transport + input + output) from a capability name and a spec. */
240
+ export function wsPlatform<
241
+ TCap extends string,
242
+ TPlatform extends string,
243
+ TAddress extends Address<TPlatform>,
244
+ TPayload,
245
+ TContent,
246
+ TKind extends string,
247
+ >(
248
+ capability: TCap,
249
+ spec: WsSpec<TPlatform, TAddress, TPayload, TContent, TKind>,
250
+ ): WsPlatform<TCap, TPlatform, TAddress, TPayload, TContent, TKind> {
251
+ return {
252
+ transport: wsTransport(capability),
253
+ input: wsInput(capability, spec),
254
+ output: wsOutput(capability, spec),
255
+ };
256
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }