@whateverjs/client 0.1.3
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/README.md +15 -0
- package/index.ts +228 -0
- package/package.json +30 -0
package/README.md
ADDED
package/index.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { WsContract, WsServerEventMap, InferWsSchema } from "@whateverjs/core";
|
|
2
|
+
|
|
3
|
+
type WsClientEventMap<TContract extends WsContract<any, any>> = {
|
|
4
|
+
[TEvent in keyof TContract["clientToServer"] & string]: InferWsSchema<
|
|
5
|
+
TContract["clientToServer"][TEvent]
|
|
6
|
+
>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface WsClientOptions {
|
|
10
|
+
protocols?: string | string[];
|
|
11
|
+
url: string;
|
|
12
|
+
connectTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ClientMessageHandler<
|
|
16
|
+
TContract extends WsContract<any, any>,
|
|
17
|
+
TEvent extends keyof WsServerEventMap<TContract> & string,
|
|
18
|
+
> = (payload: WsServerEventMap<TContract>[TEvent]) => void;
|
|
19
|
+
|
|
20
|
+
export type WireMessage =
|
|
21
|
+
| { t: "e"; e: string; d?: unknown }
|
|
22
|
+
| { t: "h"; ts: number }
|
|
23
|
+
| { t: "a"; ts: number };
|
|
24
|
+
|
|
25
|
+
const wsTextDecoder = new TextDecoder();
|
|
26
|
+
const wsHeartbeatPrefix = '{"t":"h","ts":';
|
|
27
|
+
const wsAckPrefix = '{"t":"a","ts":';
|
|
28
|
+
|
|
29
|
+
function parseCompactTimestamp(
|
|
30
|
+
value: string,
|
|
31
|
+
prefix: string,
|
|
32
|
+
): number | null {
|
|
33
|
+
if (!value.startsWith(prefix) || !value.endsWith("}")) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const timestamp = Number(value.slice(prefix.length, -1));
|
|
38
|
+
if (!Number.isFinite(timestamp)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return timestamp;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseWireMessageFromString(value: string): WireMessage | null {
|
|
46
|
+
const heartbeatTs = parseCompactTimestamp(value, wsHeartbeatPrefix);
|
|
47
|
+
if (heartbeatTs !== null) {
|
|
48
|
+
return { t: "h", ts: heartbeatTs };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ackTs = parseCompactTimestamp(value, wsAckPrefix);
|
|
52
|
+
if (ackTs !== null) {
|
|
53
|
+
return { t: "a", ts: ackTs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(value) as unknown;
|
|
58
|
+
|
|
59
|
+
if (!parsed || typeof parsed !== "object") {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const message = parsed as Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
if (message.t === "e" && typeof message.e === "string") {
|
|
66
|
+
return message as WireMessage;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
(message.t === "h" || message.t === "a") &&
|
|
71
|
+
typeof message.ts === "number"
|
|
72
|
+
) {
|
|
73
|
+
return message as WireMessage;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseWireMessage(value: unknown): WireMessage | null {
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
return parseWireMessageFromString(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (value instanceof ArrayBuffer) {
|
|
88
|
+
return parseWireMessageFromString(wsTextDecoder.decode(value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (ArrayBuffer.isView(value)) {
|
|
92
|
+
const view = value as ArrayBufferView;
|
|
93
|
+
return parseWireMessageFromString(
|
|
94
|
+
wsTextDecoder.decode(
|
|
95
|
+
new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof Blob !== "undefined" && value instanceof Blob) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function serializeEventMessage(event: string, payload: unknown): string {
|
|
108
|
+
return `{"t":"e","e":${JSON.stringify(event)},"d":${JSON.stringify(payload)}}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class TypedWsClient<TContract extends WsContract<any, any>> {
|
|
112
|
+
private socket: WebSocket | null = null;
|
|
113
|
+
private readonly handlers = new Map<
|
|
114
|
+
string,
|
|
115
|
+
Set<ClientMessageHandler<TContract, any>>
|
|
116
|
+
>();
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
readonly contract: TContract,
|
|
120
|
+
private readonly options: WsClientOptions,
|
|
121
|
+
) {}
|
|
122
|
+
|
|
123
|
+
isConnected(): boolean {
|
|
124
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async connect(): Promise<void> {
|
|
128
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const socket = new WebSocket(this.options.url, this.options.protocols);
|
|
133
|
+
this.socket = socket;
|
|
134
|
+
|
|
135
|
+
await new Promise<void>((resolve, reject) => {
|
|
136
|
+
const timeoutMs = this.options.connectTimeoutMs ?? 5_000;
|
|
137
|
+
const timeout = setTimeout(() => {
|
|
138
|
+
reject(new Error(`WebSocket connect timeout after ${timeoutMs}ms`));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
|
|
141
|
+
socket.onopen = () => {
|
|
142
|
+
clearTimeout(timeout);
|
|
143
|
+
resolve();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
socket.onerror = () => {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
reject(new Error("WebSocket connection failed"));
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
socket.onmessage = (event) => {
|
|
153
|
+
const message = parseWireMessage(event.data);
|
|
154
|
+
|
|
155
|
+
if (!message) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (message.t === "h") {
|
|
160
|
+
socket.send(`{"t":"a","ts":${message.ts}}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (message.t === "a") {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (message.t === "e") {
|
|
169
|
+
const eventHandlers = this.handlers.get(message.e);
|
|
170
|
+
|
|
171
|
+
if (!eventHandlers || eventHandlers.size === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const handler of eventHandlers) {
|
|
176
|
+
handler(message.d as never);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
disconnect(code = 1000, reason = "Client disconnect"): void {
|
|
183
|
+
if (!this.socket) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.socket.close(code, reason);
|
|
188
|
+
this.socket = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
send<TEvent extends keyof WsClientEventMap<TContract> & string>(
|
|
192
|
+
event: TEvent,
|
|
193
|
+
payload: WsClientEventMap<TContract>[TEvent],
|
|
194
|
+
): void {
|
|
195
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
196
|
+
throw new Error("WebSocket is not connected");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.socket.send(serializeEventMessage(event, payload));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
on<TEvent extends keyof WsServerEventMap<TContract> & string>(
|
|
203
|
+
event: TEvent,
|
|
204
|
+
handler: ClientMessageHandler<TContract, TEvent>,
|
|
205
|
+
): () => void {
|
|
206
|
+
const eventHandlers =
|
|
207
|
+
this.handlers.get(event) ??
|
|
208
|
+
new Set<ClientMessageHandler<TContract, any>>();
|
|
209
|
+
|
|
210
|
+
eventHandlers.add(handler as ClientMessageHandler<TContract, any>);
|
|
211
|
+
this.handlers.set(event, eventHandlers);
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
eventHandlers.delete(handler as ClientMessageHandler<TContract, any>);
|
|
215
|
+
|
|
216
|
+
if (eventHandlers.size === 0) {
|
|
217
|
+
this.handlers.delete(event);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export { TypedHttpClient } from "./http-client";
|
|
224
|
+
export type {
|
|
225
|
+
HttpClientRequestOptions,
|
|
226
|
+
HttpRouteDefinition,
|
|
227
|
+
HttpRouteMap,
|
|
228
|
+
} from "./http-client";
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@whateverjs/client",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"main": "./index.ts",
|
|
5
|
+
"module": "./index.ts",
|
|
6
|
+
"types": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.ts",
|
|
10
|
+
"default": "./index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.ts",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"type": "module",
|
|
19
|
+
"private": false,
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "latest"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@whateverjs/core": "^0.1.0",
|
|
28
|
+
"typescript": "^5"
|
|
29
|
+
}
|
|
30
|
+
}
|