@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 +8 -0
- package/package.json +14 -0
- package/src/index.ts +256 -0
- package/tsconfig.json +4 -0
package/CHANGELOG.md
ADDED
package/package.json
ADDED
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