@qubic.ts/sdk 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.
@@ -0,0 +1,393 @@
1
+ import { SdkError } from "../errors.js";
2
+ import type { FetchLike } from "../http.js";
3
+ import { normalizeRetryConfig, type RetryConfig, withRetry } from "../retry.js";
4
+
5
+ export type BobClientConfig = Readonly<{
6
+ /** Base URL for QubicBob (default: http://localhost:40420). */
7
+ baseUrl?: string;
8
+ fetch?: FetchLike;
9
+ headers?: Readonly<Record<string, string>>;
10
+ retry?: RetryConfig;
11
+ onRequest?: (info: Readonly<{ url: string; method: string; body?: unknown }>) => void;
12
+ onResponse?: (
13
+ info: Readonly<{
14
+ url: string;
15
+ method: string;
16
+ status: number;
17
+ ok: boolean;
18
+ durationMs: number;
19
+ }>,
20
+ ) => void;
21
+ onError?: (error: BobError) => void;
22
+ }>;
23
+
24
+ export class BobError extends SdkError {
25
+ override name = "BobError";
26
+
27
+ constructor(
28
+ code: string,
29
+ message: string,
30
+ readonly details: Readonly<{
31
+ url: string;
32
+ method: string;
33
+ status?: number;
34
+ statusText?: string;
35
+ bodyText?: string;
36
+ }>,
37
+ cause?: unknown,
38
+ ) {
39
+ super(code, message, details, cause);
40
+ }
41
+ }
42
+
43
+ export type BobQuerySmartContractInput = Readonly<{
44
+ nonce?: number;
45
+ scIndex: number;
46
+ funcNumber: number;
47
+ dataHex?: string;
48
+ dataBytes?: Uint8Array;
49
+ }>;
50
+
51
+ export type BobQuerySmartContractResult = Readonly<{
52
+ nonce: number;
53
+ pending: boolean;
54
+ dataHex?: string;
55
+ message?: string;
56
+ }>;
57
+
58
+ export type BobClient = Readonly<{
59
+ status(): Promise<unknown>;
60
+ balance(identity: string): Promise<unknown>;
61
+ asset(input: {
62
+ identity: string;
63
+ issuer: string;
64
+ assetName: string;
65
+ manageSCIndex: number;
66
+ }): Promise<unknown>;
67
+ epochInfo(epoch: number): Promise<unknown>;
68
+ tx(hash: string): Promise<unknown>;
69
+ logRange(input: { epoch: number; fromId: number; toId: number }): Promise<unknown>;
70
+ tick(tickNumber: number): Promise<unknown>;
71
+ findLog(input: {
72
+ fromTick: number;
73
+ toTick: number;
74
+ scIndex: number;
75
+ logType: number;
76
+ topic1: string;
77
+ topic2: string;
78
+ topic3: string;
79
+ }): Promise<unknown>;
80
+ getLogCustom(input: {
81
+ epoch: number;
82
+ tick: number;
83
+ scIndex: number;
84
+ logType: number;
85
+ topic1: string;
86
+ topic2: string;
87
+ topic3: string;
88
+ }): Promise<unknown>;
89
+ querySmartContract(input: BobQuerySmartContractInput): Promise<BobQuerySmartContractResult>;
90
+ broadcastTransaction(input: { dataHex?: string; dataBytes?: Uint8Array }): Promise<unknown>;
91
+ getQuTransfersForIdentity(input: {
92
+ fromTick: number;
93
+ toTick: number;
94
+ identity: string;
95
+ }): Promise<unknown>;
96
+ getAssetTransfersForIdentity(input: {
97
+ fromTick: number;
98
+ toTick: number;
99
+ identity: string;
100
+ assetIssuer: string;
101
+ assetName: string;
102
+ }): Promise<unknown>;
103
+ getAllAssetTransfers(input: {
104
+ fromTick: number;
105
+ toTick: number;
106
+ assetIssuer: string;
107
+ assetName: string;
108
+ }): Promise<unknown>;
109
+ }>;
110
+
111
+ export function createBobClient(config: BobClientConfig = {}): BobClient {
112
+ const baseUrl = ensureTrailingSlash(config.baseUrl ?? "http://localhost:40420");
113
+ const base = new URL(baseUrl);
114
+ const doFetch = config.fetch ?? fetch;
115
+ const retryConfig = normalizeRetryConfig(config.retry);
116
+
117
+ const requestJson = async (method: string, url: URL, body?: unknown): Promise<unknown> => {
118
+ return withRetry(
119
+ retryConfig,
120
+ method,
121
+ async () => {
122
+ const start = Date.now();
123
+ const headers: Record<string, string> = {
124
+ accept: "application/json",
125
+ ...config.headers,
126
+ };
127
+ let bodyText: string | undefined;
128
+ if (body !== undefined) {
129
+ headers["content-type"] = "application/json";
130
+ bodyText = JSON.stringify(body);
131
+ }
132
+
133
+ try {
134
+ config.onRequest?.({ url: url.toString(), method, body });
135
+ const res = await doFetch(url, {
136
+ method,
137
+ headers,
138
+ body: bodyText,
139
+ });
140
+ config.onResponse?.({
141
+ url: url.toString(),
142
+ method,
143
+ status: res.status,
144
+ ok: res.ok,
145
+ durationMs: Date.now() - start,
146
+ });
147
+
148
+ const text = await res.text();
149
+ if (!res.ok) {
150
+ const error = new BobError(
151
+ "bob_request_failed",
152
+ `QubicBob request failed: ${res.status} ${res.statusText}`,
153
+ {
154
+ url: url.toString(),
155
+ method,
156
+ status: res.status,
157
+ statusText: res.statusText,
158
+ bodyText: text || undefined,
159
+ },
160
+ );
161
+ config.onError?.(error);
162
+ throw error;
163
+ }
164
+
165
+ if (text.length === 0) return null;
166
+ try {
167
+ return JSON.parse(text) as unknown;
168
+ } catch {
169
+ const error = new BobError("bob_invalid_json", "QubicBob response was not valid JSON", {
170
+ url: url.toString(),
171
+ method,
172
+ status: res.status,
173
+ statusText: res.statusText,
174
+ bodyText: text || undefined,
175
+ });
176
+ config.onError?.(error);
177
+ throw error;
178
+ }
179
+ } catch (error) {
180
+ if (error instanceof BobError) throw error;
181
+ const wrapped = new BobError(
182
+ "bob_fetch_error",
183
+ "QubicBob fetch failed",
184
+ {
185
+ url: url.toString(),
186
+ method,
187
+ },
188
+ error,
189
+ );
190
+ config.onError?.(wrapped);
191
+ throw wrapped;
192
+ }
193
+ },
194
+ (error) => shouldRetryBob(error, retryConfig),
195
+ );
196
+ };
197
+
198
+ return {
199
+ async status(): Promise<unknown> {
200
+ const url = new URL("status", base);
201
+ return requestJson("GET", url);
202
+ },
203
+
204
+ async balance(identity: string): Promise<unknown> {
205
+ const url = new URL(`balance/${encodeURIComponent(identity)}`, base);
206
+ return requestJson("GET", url);
207
+ },
208
+
209
+ async asset(input): Promise<unknown> {
210
+ const url = new URL(
211
+ `asset/${encodeURIComponent(input.identity)}/${encodeURIComponent(
212
+ input.issuer,
213
+ )}/${encodeURIComponent(input.assetName)}/${input.manageSCIndex}`,
214
+ base,
215
+ );
216
+ return requestJson("GET", url);
217
+ },
218
+
219
+ async epochInfo(epoch: number): Promise<unknown> {
220
+ const url = new URL(`epochinfo/${epoch}`, base);
221
+ return requestJson("GET", url);
222
+ },
223
+
224
+ async tx(hash: string): Promise<unknown> {
225
+ const url = new URL(`tx/${encodeURIComponent(hash)}`, base);
226
+ return requestJson("GET", url);
227
+ },
228
+
229
+ async logRange(input): Promise<unknown> {
230
+ const url = new URL(`log/${input.epoch}/${input.fromId}/${input.toId}`, base);
231
+ return requestJson("GET", url);
232
+ },
233
+
234
+ async tick(tickNumber: number): Promise<unknown> {
235
+ const url = new URL(`tick/${tickNumber}`, base);
236
+ return requestJson("GET", url);
237
+ },
238
+
239
+ async findLog(input): Promise<unknown> {
240
+ const url = new URL("findLog", base);
241
+ return requestJson("POST", url, input);
242
+ },
243
+
244
+ async getLogCustom(input): Promise<unknown> {
245
+ const url = new URL("getlogcustom", base);
246
+ return requestJson("POST", url, input);
247
+ },
248
+
249
+ async querySmartContract(
250
+ input: BobQuerySmartContractInput,
251
+ ): Promise<BobQuerySmartContractResult> {
252
+ const nonce = input.nonce ?? randomUint32();
253
+ const dataHex = input.dataHex ?? (input.dataBytes ? toHex(input.dataBytes) : "");
254
+ const url = new URL("querySmartContract", base);
255
+
256
+ const headers: Record<string, string> = {
257
+ accept: "application/json",
258
+ "content-type": "application/json",
259
+ ...config.headers,
260
+ };
261
+ const bodyText = JSON.stringify({
262
+ nonce,
263
+ scIndex: input.scIndex,
264
+ funcNumber: input.funcNumber,
265
+ data: dataHex,
266
+ });
267
+
268
+ return withRetry(
269
+ retryConfig,
270
+ "POST",
271
+ async () => {
272
+ const start = Date.now();
273
+ config.onRequest?.({ url: url.toString(), method: "POST", body: JSON.parse(bodyText) });
274
+ const res = await doFetch(url, { method: "POST", headers, body: bodyText });
275
+ config.onResponse?.({
276
+ url: url.toString(),
277
+ method: "POST",
278
+ status: res.status,
279
+ ok: res.ok,
280
+ durationMs: Date.now() - start,
281
+ });
282
+ const text = await res.text();
283
+ let json: unknown = null;
284
+ if (text.length) {
285
+ try {
286
+ json = JSON.parse(text) as unknown;
287
+ } catch {
288
+ const error = new BobError(
289
+ "bob_invalid_json",
290
+ "QubicBob response was not valid JSON",
291
+ {
292
+ url: url.toString(),
293
+ method: "POST",
294
+ status: res.status,
295
+ statusText: res.statusText,
296
+ bodyText: text || undefined,
297
+ },
298
+ );
299
+ config.onError?.(error);
300
+ throw error;
301
+ }
302
+ }
303
+
304
+ if (res.status === 202) {
305
+ const obj = expectObject(json);
306
+ return {
307
+ nonce,
308
+ pending: true,
309
+ message: typeof obj.message === "string" ? obj.message : "pending",
310
+ };
311
+ }
312
+
313
+ if (!res.ok) {
314
+ const error = new BobError(
315
+ "bob_request_failed",
316
+ `QubicBob request failed: ${res.status} ${res.statusText}`,
317
+ {
318
+ url: url.toString(),
319
+ method: "POST",
320
+ status: res.status,
321
+ statusText: res.statusText,
322
+ bodyText: text || undefined,
323
+ },
324
+ );
325
+ config.onError?.(error);
326
+ throw error;
327
+ }
328
+
329
+ const obj = expectObject(json);
330
+ return {
331
+ nonce,
332
+ pending: false,
333
+ dataHex: typeof obj.data === "string" ? obj.data : undefined,
334
+ };
335
+ },
336
+ (error) => shouldRetryBob(error, retryConfig),
337
+ );
338
+ },
339
+
340
+ async broadcastTransaction(input): Promise<unknown> {
341
+ const dataHex = input.dataHex ?? (input.dataBytes ? toHex(input.dataBytes) : "");
342
+ if (!dataHex) throw new TypeError("broadcastTransaction requires dataHex or dataBytes");
343
+ const url = new URL("broadcastTransaction", base);
344
+ return requestJson("POST", url, { data: dataHex });
345
+ },
346
+
347
+ async getQuTransfersForIdentity(input): Promise<unknown> {
348
+ const url = new URL("getQuTransfersForIdentity", base);
349
+ return requestJson("POST", url, input);
350
+ },
351
+
352
+ async getAssetTransfersForIdentity(input): Promise<unknown> {
353
+ const url = new URL("getAssetTransfersForIdentity", base);
354
+ return requestJson("POST", url, input);
355
+ },
356
+
357
+ async getAllAssetTransfers(input): Promise<unknown> {
358
+ const url = new URL("getAllAssetTransfers", base);
359
+ return requestJson("POST", url, input);
360
+ },
361
+ };
362
+ }
363
+
364
+ function ensureTrailingSlash(value: string): string {
365
+ return value.endsWith("/") ? value : `${value}/`;
366
+ }
367
+
368
+ function expectObject(value: unknown): Record<string, unknown> {
369
+ if (!value || typeof value !== "object") return {};
370
+ return value as Record<string, unknown>;
371
+ }
372
+
373
+ function toHex(bytes: Uint8Array): string {
374
+ let out = "";
375
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
376
+ return out;
377
+ }
378
+
379
+ function randomUint32(): number {
380
+ if (typeof crypto !== "undefined" && "getRandomValues" in crypto) {
381
+ const buf = new Uint32Array(1);
382
+ crypto.getRandomValues(buf);
383
+ return buf[0] ?? 0;
384
+ }
385
+ return Math.floor(Math.random() * 0xffff_ffff);
386
+ }
387
+
388
+ function shouldRetryBob(error: unknown, config: ReturnType<typeof normalizeRetryConfig>): boolean {
389
+ if (!(error instanceof BobError)) return false;
390
+ if (error.code === "bob_fetch_error") return true;
391
+ const status = error.details.status;
392
+ return typeof status === "number" && config.retryOnStatuses.includes(status);
393
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { WebSocketLike } from "./log-stream.js";
3
+ import { createLogStream } from "./log-stream.js";
4
+
5
+ class FakeWebSocket implements WebSocketLike {
6
+ static instances: FakeWebSocket[] = [];
7
+ readonly readyState = 0;
8
+ onopen: ((event: Record<string, unknown>) => void) | null = null;
9
+ onmessage: ((event: { data: string }) => void) | null = null;
10
+ onerror: ((event: Record<string, unknown>) => void) | null = null;
11
+ onclose: ((event: { code?: number; reason?: string }) => void) | null = null;
12
+ sent: string[] = [];
13
+
14
+ constructor(readonly url: string) {
15
+ FakeWebSocket.instances.push(this);
16
+ }
17
+
18
+ send(data: string) {
19
+ this.sent.push(data);
20
+ }
21
+
22
+ close() {
23
+ this.onclose?.({});
24
+ }
25
+
26
+ open() {
27
+ this.onopen?.({});
28
+ }
29
+ }
30
+
31
+ describe("log stream", () => {
32
+ it("sends batch subscribe on open", async () => {
33
+ const stream = createLogStream({
34
+ baseUrl: "http://example.test",
35
+ subscriptions: [
36
+ { scIndex: 1, logType: 100001 },
37
+ { scIndex: 2, logType: 100002 },
38
+ ],
39
+ webSocketFactory: (url) => new FakeWebSocket(url),
40
+ });
41
+
42
+ const socket = FakeWebSocket.instances[0];
43
+ expect(socket).toBeDefined();
44
+ if (!socket) throw new Error("Missing test WebSocket instance");
45
+ socket.open();
46
+ await new Promise((resolve) => setTimeout(resolve, 0));
47
+
48
+ const sent = socket.sent.map((s) => JSON.parse(s));
49
+ expect(sent.length).toBe(1);
50
+ expect(sent[0]?.action).toBe("subscribe");
51
+ expect(Array.isArray(sent[0]?.subscriptions)).toBe(true);
52
+ expect(sent[0]?.subscriptions?.length).toBe(2);
53
+ expect(stream.socket).toBe(socket);
54
+ });
55
+ });
@@ -0,0 +1,241 @@
1
+ export type LogSubscription = Readonly<{
2
+ scIndex: number;
3
+ logType: number;
4
+ lastTick?: number;
5
+ lastLogId?: number;
6
+ }>;
7
+
8
+ export type LogCursor = Readonly<{ lastTick?: number; lastLogId?: number }>;
9
+
10
+ export type LogCursorStore = Readonly<{
11
+ get(key: string): LogCursor | Promise<LogCursor | undefined> | undefined;
12
+ set(key: string, cursor: LogCursor): void | Promise<void>;
13
+ }>;
14
+
15
+ export type LogStreamHandlers = Readonly<{
16
+ onOpen?: () => void;
17
+ onClose?: (event: CloseEventLike) => void;
18
+ onError?: (event: EventLike) => void;
19
+ onWelcome?: (message: Record<string, unknown>) => void;
20
+ onAck?: (message: Record<string, unknown>) => void;
21
+ onLog?: (message: Record<string, unknown>) => void;
22
+ onCatchUpComplete?: (message: Record<string, unknown>) => void;
23
+ onPong?: (message: Record<string, unknown>) => void;
24
+ onServerError?: (message: Record<string, unknown>) => void;
25
+ }>;
26
+
27
+ export type LogStreamConfig = LogStreamHandlers &
28
+ Readonly<{
29
+ baseUrl: string;
30
+ subscriptions?: readonly LogSubscription[];
31
+ lastTick?: number;
32
+ lastLogId?: number;
33
+ cursorStore?: LogCursorStore;
34
+ webSocketFactory?: (url: string) => WebSocketLike;
35
+ signal?: AbortSignal;
36
+ }>;
37
+
38
+ export type LogStream = Readonly<{
39
+ socket: WebSocketLike;
40
+ subscribe(sub: LogSubscription): void;
41
+ subscribeMany(subs: readonly LogSubscription[], cursor?: LogCursor): void;
42
+ unsubscribe(sub: LogSubscription): void;
43
+ unsubscribeAll(): void;
44
+ ping(): void;
45
+ close(code?: number, reason?: string): void;
46
+ }>;
47
+
48
+ export function createLogStream(config: LogStreamConfig): LogStream {
49
+ const wsUrl = toWebSocketUrl(config.baseUrl);
50
+ const createSocket = config.webSocketFactory ?? defaultWebSocketFactory;
51
+ const socket = createSocket(wsUrl);
52
+
53
+ const pending: string[] = [];
54
+ let open = false;
55
+
56
+ const sendMessage = (message: Record<string, unknown>) => {
57
+ const text = JSON.stringify(message);
58
+ if (!open) {
59
+ pending.push(text);
60
+ return;
61
+ }
62
+ socket.send(text);
63
+ };
64
+
65
+ socket.onopen = () => {
66
+ open = true;
67
+ for (const text of pending.splice(0, pending.length)) socket.send(text);
68
+ config.onOpen?.();
69
+ if (config.subscriptions?.length) {
70
+ bootstrapSubscriptions(config.subscriptions);
71
+ }
72
+ };
73
+
74
+ socket.onmessage = (event) => {
75
+ const data = typeof event.data === "string" ? event.data : "";
76
+ let message: Record<string, unknown> | null = null;
77
+ try {
78
+ message = JSON.parse(data) as Record<string, unknown>;
79
+ } catch {
80
+ return;
81
+ }
82
+ if (!message) return;
83
+
84
+ const type = typeof message.type === "string" ? message.type : "";
85
+ if (type === "welcome") config.onWelcome?.(message);
86
+ else if (type === "ack") config.onAck?.(message);
87
+ else if (type === "log") {
88
+ config.onLog?.(message);
89
+ maybeUpdateCursor(message);
90
+ } else if (type === "catchUpComplete") config.onCatchUpComplete?.(message);
91
+ else if (type === "pong") config.onPong?.(message);
92
+ else if (type === "error") config.onServerError?.(message);
93
+ };
94
+
95
+ socket.onerror = (event) => {
96
+ config.onError?.(event);
97
+ };
98
+
99
+ socket.onclose = (event) => {
100
+ open = false;
101
+ config.onClose?.(event);
102
+ };
103
+
104
+ if (config.signal) {
105
+ if (config.signal.aborted) socket.close();
106
+ else config.signal.addEventListener("abort", () => socket.close(), { once: true });
107
+ }
108
+
109
+ const subscribe = (sub: LogSubscription) => {
110
+ sendMessage({
111
+ action: "subscribe",
112
+ scIndex: sub.scIndex,
113
+ logType: sub.logType,
114
+ ...(sub.lastLogId !== undefined ? { lastLogId: sub.lastLogId } : {}),
115
+ ...(sub.lastTick !== undefined && sub.lastLogId === undefined
116
+ ? { lastTick: sub.lastTick }
117
+ : {}),
118
+ });
119
+ };
120
+
121
+ const subscribeMany = (subs: readonly LogSubscription[], cursor?: LogCursor) => {
122
+ sendMessage({
123
+ action: "subscribe",
124
+ ...(cursor?.lastLogId !== undefined ? { lastLogId: cursor.lastLogId } : {}),
125
+ ...(cursor?.lastTick !== undefined && cursor.lastLogId === undefined
126
+ ? { lastTick: cursor.lastTick }
127
+ : {}),
128
+ subscriptions: subs.map((s) => ({ scIndex: s.scIndex, logType: s.logType })),
129
+ });
130
+ };
131
+
132
+ const unsubscribe = (sub: LogSubscription) => {
133
+ sendMessage({
134
+ action: "unsubscribe",
135
+ scIndex: sub.scIndex,
136
+ logType: sub.logType,
137
+ });
138
+ };
139
+
140
+ const unsubscribeAll = () => {
141
+ sendMessage({ action: "unsubscribeAll" });
142
+ };
143
+
144
+ const ping = () => {
145
+ sendMessage({ action: "ping" });
146
+ };
147
+
148
+ const close = (code?: number, reason?: string) => {
149
+ socket.close(code, reason);
150
+ };
151
+
152
+ const bootstrapSubscriptions = async (subs: readonly LogSubscription[]) => {
153
+ const withCursor: LogSubscription[] = [];
154
+ for (const s of subs) {
155
+ const cursor = await getCursorFor(s);
156
+ withCursor.push({ ...s, ...cursor });
157
+ }
158
+
159
+ const hasPerCursor = withCursor.some(
160
+ (s) => s.lastLogId !== undefined || s.lastTick !== undefined,
161
+ );
162
+ if (!hasPerCursor && withCursor.length > 1) {
163
+ subscribeMany(withCursor, {
164
+ lastLogId: config.lastLogId,
165
+ lastTick: config.lastTick,
166
+ });
167
+ return;
168
+ }
169
+
170
+ for (const sub of withCursor) subscribe(sub);
171
+ };
172
+
173
+ const getCursorFor = async (sub: LogSubscription): Promise<LogCursor | undefined> => {
174
+ if (sub.lastLogId !== undefined || sub.lastTick !== undefined) {
175
+ return { lastLogId: sub.lastLogId, lastTick: sub.lastTick };
176
+ }
177
+ if (!config.cursorStore) return undefined;
178
+ return config.cursorStore.get(cursorKey(sub.scIndex, sub.logType));
179
+ };
180
+
181
+ const maybeUpdateCursor = (message: Record<string, unknown>) => {
182
+ if (!config.cursorStore) return;
183
+ const scIndex = asNumber(message.scIndex);
184
+ const logType = asNumber(message.logType);
185
+ if (scIndex === undefined || logType === undefined) return;
186
+
187
+ const payload = expectObject(message.message);
188
+ const logId = asNumber(payload.logId ?? payload.id);
189
+ const tick = asNumber(payload.tick ?? payload.tickNumber);
190
+ if (logId === undefined && tick === undefined) return;
191
+
192
+ const cursor: LogCursor = logId !== undefined ? { lastLogId: logId } : { lastTick: tick };
193
+ config.cursorStore.set(cursorKey(scIndex, logType), cursor);
194
+ };
195
+
196
+ return { socket, subscribe, subscribeMany, unsubscribe, unsubscribeAll, ping, close };
197
+ }
198
+
199
+ function toWebSocketUrl(baseUrl: string): string {
200
+ const url = new URL(baseUrl);
201
+ const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
202
+ url.protocol = wsProtocol;
203
+ const basePath = url.pathname.replace(/\/$/, "");
204
+ url.pathname = `${basePath}/ws/logs`;
205
+ return url.toString();
206
+ }
207
+
208
+ function cursorKey(scIndex: number, logType: number): string {
209
+ return `${scIndex}:${logType}`;
210
+ }
211
+
212
+ function expectObject(value: unknown): Record<string, unknown> {
213
+ if (!value || typeof value !== "object") return {};
214
+ return value as Record<string, unknown>;
215
+ }
216
+
217
+ function asNumber(value: unknown): number | undefined {
218
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
219
+ return value;
220
+ }
221
+
222
+ function defaultWebSocketFactory(url: string): WebSocketLike {
223
+ if (typeof WebSocket === "undefined") {
224
+ throw new Error("WebSocket is not available; provide webSocketFactory");
225
+ }
226
+ return new WebSocket(url) as WebSocketLike;
227
+ }
228
+
229
+ export type WebSocketLike = {
230
+ readonly readyState: number;
231
+ onopen: ((event: EventLike) => void) | null;
232
+ onmessage: ((event: MessageEventLike) => void) | null;
233
+ onerror: ((event: EventLike) => void) | null;
234
+ onclose: ((event: CloseEventLike) => void) | null;
235
+ send(data: string): void;
236
+ close(code?: number, reason?: string): void;
237
+ };
238
+
239
+ export type EventLike = Readonly<Record<string, unknown>>;
240
+ export type MessageEventLike = Readonly<{ data: string }>;
241
+ export type CloseEventLike = Readonly<{ code?: number; reason?: string }>;