@lasersell/lasersell-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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/exitApi.d.ts +87 -0
- package/dist/exitApi.js +238 -0
- package/dist/exitApi.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/retry.d.ts +15 -0
- package/dist/retry.js +74 -0
- package/dist/retry.js.map +1 -0
- package/dist/stream/client.d.ts +144 -0
- package/dist/stream/client.js +995 -0
- package/dist/stream/client.js.map +1 -0
- package/dist/stream/index.d.ts +3 -0
- package/dist/stream/index.js +4 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/proto.d.ts +148 -0
- package/dist/stream/proto.js +266 -0
- package/dist/stream/proto.js.map +1 -0
- package/dist/stream/session.d.ts +64 -0
- package/dist/stream/session.js +243 -0
- package/dist/stream/session.js.map +1 -0
- package/dist/tx.d.ts +85 -0
- package/dist/tx.js +408 -0
- package/dist/tx.js.map +1 -0
- package/package.json +87 -0
- package/src/exitApi.ts +385 -0
- package/src/index.ts +5 -0
- package/src/retry.ts +102 -0
- package/src/stream/client.ts +1362 -0
- package/src/stream/index.ts +3 -0
- package/src/stream/proto.ts +565 -0
- package/src/stream/session.ts +372 -0
- package/src/tx.ts +617 -0
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
import WebSocket, { type RawData } from "ws";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clientMessageToText,
|
|
5
|
+
serverMessageFromText,
|
|
6
|
+
type ClientMessage,
|
|
7
|
+
type ServerMessage,
|
|
8
|
+
type StrategyConfigMsg,
|
|
9
|
+
} from "./proto.js";
|
|
10
|
+
|
|
11
|
+
const MIN_RECONNECT_BACKOFF_MS = 100;
|
|
12
|
+
const MAX_RECONNECT_BACKOFF_MS = 2_000;
|
|
13
|
+
|
|
14
|
+
export const STREAM_ENDPOINT = "wss://stream.lasersell.io/v1/ws";
|
|
15
|
+
export const LOCAL_STREAM_ENDPOINT = "ws://localhost:8082/v1/ws";
|
|
16
|
+
|
|
17
|
+
export interface StreamConfigure {
|
|
18
|
+
wallet_pubkeys: string[];
|
|
19
|
+
strategy: StrategyConfigMsg;
|
|
20
|
+
deadline_timeout_sec?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OptionalStrategyConfig {
|
|
24
|
+
target_profit_pct?: number;
|
|
25
|
+
stop_loss_pct?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StreamLanesOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Maximum number of low-priority messages (currently `pnl_update`) to buffer.
|
|
31
|
+
*
|
|
32
|
+
* When full, the oldest low-priority message is dropped to keep the stream hot path responsive.
|
|
33
|
+
*
|
|
34
|
+
* Defaults to 1024.
|
|
35
|
+
*/
|
|
36
|
+
lowPriorityCapacity?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function singleWalletStreamConfigure(
|
|
40
|
+
walletPubkey: string,
|
|
41
|
+
strategy: StrategyConfigMsg,
|
|
42
|
+
deadlineTimeoutSec?: number,
|
|
43
|
+
): StreamConfigure {
|
|
44
|
+
return {
|
|
45
|
+
wallet_pubkeys: [walletPubkey],
|
|
46
|
+
strategy,
|
|
47
|
+
deadline_timeout_sec: deadlineTimeoutSec,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function strategyConfigFromOptional(
|
|
52
|
+
strategy: OptionalStrategyConfig = {},
|
|
53
|
+
): StrategyConfigMsg {
|
|
54
|
+
return {
|
|
55
|
+
target_profit_pct: strategy.target_profit_pct ?? 0,
|
|
56
|
+
stop_loss_pct: strategy.stop_loss_pct ?? 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function singleWalletStreamConfigureOptional(
|
|
61
|
+
walletPubkey: string,
|
|
62
|
+
strategy: OptionalStrategyConfig = {},
|
|
63
|
+
deadlineTimeoutSec = 0,
|
|
64
|
+
): StreamConfigure {
|
|
65
|
+
return {
|
|
66
|
+
wallet_pubkeys: [walletPubkey],
|
|
67
|
+
strategy: strategyConfigFromOptional(strategy),
|
|
68
|
+
deadline_timeout_sec: deadlineTimeoutSec,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type PositionSelector =
|
|
73
|
+
| { token_account: string; position_id?: never }
|
|
74
|
+
| { position_id: number; token_account?: never };
|
|
75
|
+
|
|
76
|
+
export type PositionSelectorInput =
|
|
77
|
+
| PositionSelector
|
|
78
|
+
| string
|
|
79
|
+
| number
|
|
80
|
+
| { tokenAccount: string }
|
|
81
|
+
| { positionId: number }
|
|
82
|
+
| { token_account: string }
|
|
83
|
+
| { position_id: number };
|
|
84
|
+
|
|
85
|
+
export type StreamClientErrorKind =
|
|
86
|
+
| "websocket"
|
|
87
|
+
| "json"
|
|
88
|
+
| "invalid_api_key_header"
|
|
89
|
+
| "send_queue_closed"
|
|
90
|
+
| "protocol";
|
|
91
|
+
|
|
92
|
+
export class StreamClientError extends Error {
|
|
93
|
+
readonly kind: StreamClientErrorKind;
|
|
94
|
+
|
|
95
|
+
private constructor(
|
|
96
|
+
kind: StreamClientErrorKind,
|
|
97
|
+
message: string,
|
|
98
|
+
cause?: unknown,
|
|
99
|
+
) {
|
|
100
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
101
|
+
this.name = "StreamClientError";
|
|
102
|
+
this.kind = kind;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static websocket(cause: unknown): StreamClientError {
|
|
106
|
+
return new StreamClientError(
|
|
107
|
+
"websocket",
|
|
108
|
+
`websocket error: ${stringifyError(cause)}`,
|
|
109
|
+
cause,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static json(cause: unknown): StreamClientError {
|
|
114
|
+
return new StreamClientError(
|
|
115
|
+
"json",
|
|
116
|
+
`json error: ${stringifyError(cause)}`,
|
|
117
|
+
cause,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static invalidApiKeyHeader(cause: unknown): StreamClientError {
|
|
122
|
+
return new StreamClientError(
|
|
123
|
+
"invalid_api_key_header",
|
|
124
|
+
`invalid api-key header: ${stringifyError(cause)}`,
|
|
125
|
+
cause,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static sendQueueClosed(): StreamClientError {
|
|
130
|
+
return new StreamClientError("send_queue_closed", "send queue is closed");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static protocol(message: string): StreamClientError {
|
|
134
|
+
return new StreamClientError("protocol", `protocol error: ${message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class StreamClient {
|
|
139
|
+
private readonly apiKey: string;
|
|
140
|
+
private local = false;
|
|
141
|
+
private endpointOverride?: string;
|
|
142
|
+
|
|
143
|
+
constructor(apiKey: string) {
|
|
144
|
+
this.apiKey = apiKey;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
withLocalMode(local: boolean): this {
|
|
148
|
+
this.local = local;
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
withEndpoint(endpoint: string): this {
|
|
153
|
+
this.endpointOverride = endpoint.trimEnd();
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async connect(configure: StreamConfigure): Promise<StreamConnection> {
|
|
158
|
+
validateStrategyAndDeadline(
|
|
159
|
+
configure.strategy,
|
|
160
|
+
configure.deadline_timeout_sec ?? 0,
|
|
161
|
+
);
|
|
162
|
+
const worker = new StreamConnectionWorker(
|
|
163
|
+
this.endpoint(),
|
|
164
|
+
this.apiKey,
|
|
165
|
+
configure,
|
|
166
|
+
);
|
|
167
|
+
await worker.waitReady();
|
|
168
|
+
return new StreamConnection(worker);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async connectLanes(
|
|
172
|
+
configure: StreamConfigure,
|
|
173
|
+
options: StreamLanesOptions = {},
|
|
174
|
+
): Promise<StreamConnectionLanes> {
|
|
175
|
+
validateStrategyAndDeadline(
|
|
176
|
+
configure.strategy,
|
|
177
|
+
configure.deadline_timeout_sec ?? 0,
|
|
178
|
+
);
|
|
179
|
+
const worker = new StreamConnectionWorker(
|
|
180
|
+
this.endpoint(),
|
|
181
|
+
this.apiKey,
|
|
182
|
+
configure,
|
|
183
|
+
{
|
|
184
|
+
inboundMode: "lanes",
|
|
185
|
+
lowPriorityCapacity: options.lowPriorityCapacity,
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
await worker.waitReady();
|
|
189
|
+
return new StreamConnectionLanes(worker);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private endpoint(): string {
|
|
193
|
+
if (this.endpointOverride !== undefined) {
|
|
194
|
+
return this.endpointOverride;
|
|
195
|
+
}
|
|
196
|
+
if (this.local) {
|
|
197
|
+
return LOCAL_STREAM_ENDPOINT;
|
|
198
|
+
}
|
|
199
|
+
return STREAM_ENDPOINT;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function validateStrategyAndDeadline(
|
|
204
|
+
strategy: StrategyConfigMsg,
|
|
205
|
+
deadlineTimeoutSec: number,
|
|
206
|
+
): void {
|
|
207
|
+
validateStrategyValue(strategy.target_profit_pct, "strategy.target_profit_pct");
|
|
208
|
+
validateStrategyValue(strategy.stop_loss_pct, "strategy.stop_loss_pct");
|
|
209
|
+
|
|
210
|
+
const deadline = Number(deadlineTimeoutSec);
|
|
211
|
+
if (!Number.isFinite(deadline)) {
|
|
212
|
+
throw StreamClientError.protocol(
|
|
213
|
+
"deadline_timeout_sec must be a finite number",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (deadline < 0) {
|
|
217
|
+
throw StreamClientError.protocol("deadline_timeout_sec must be >= 0");
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
strategy.target_profit_pct > 0 ||
|
|
221
|
+
strategy.stop_loss_pct > 0 ||
|
|
222
|
+
deadline > 0
|
|
223
|
+
) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw StreamClientError.protocol(
|
|
228
|
+
"at least one of strategy.target_profit_pct, strategy.stop_loss_pct, or deadline_timeout_sec must be > 0",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function validateStrategyValue(value: number, field: string): void {
|
|
233
|
+
if (!Number.isFinite(value)) {
|
|
234
|
+
throw StreamClientError.protocol(`${field} must be a finite number`);
|
|
235
|
+
}
|
|
236
|
+
if (value < 0) {
|
|
237
|
+
throw StreamClientError.protocol(`${field} must be >= 0`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class StreamConnection {
|
|
242
|
+
private readonly worker: StreamConnectionWorker;
|
|
243
|
+
|
|
244
|
+
constructor(worker: StreamConnectionWorker) {
|
|
245
|
+
this.worker = worker;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
sender(): StreamSender {
|
|
249
|
+
return new StreamSender(this.worker);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
split(): [StreamSender, StreamReceiver] {
|
|
253
|
+
return [this.sender(), new StreamReceiver(this.worker)];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async recv(): Promise<ServerMessage | null> {
|
|
257
|
+
return await this.worker.recv();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
close(): void {
|
|
261
|
+
this.worker.close();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export class StreamConnectionLanes {
|
|
266
|
+
private readonly worker: StreamConnectionWorker;
|
|
267
|
+
|
|
268
|
+
constructor(worker: StreamConnectionWorker) {
|
|
269
|
+
this.worker = worker;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
sender(): StreamSender {
|
|
273
|
+
return new StreamSender(this.worker);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
highReceiver(): StreamHighReceiver {
|
|
277
|
+
return new StreamHighReceiver(this.worker);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lowReceiver(): StreamLowReceiver {
|
|
281
|
+
return new StreamLowReceiver(this.worker);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
split(): [StreamSender, StreamHighReceiver, StreamLowReceiver] {
|
|
285
|
+
return [this.sender(), this.highReceiver(), this.lowReceiver()];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
close(): void {
|
|
289
|
+
this.worker.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export class StreamHighReceiver {
|
|
294
|
+
private readonly worker: StreamConnectionWorker;
|
|
295
|
+
|
|
296
|
+
constructor(worker: StreamConnectionWorker) {
|
|
297
|
+
this.worker = worker;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async recv(): Promise<ServerMessage | null> {
|
|
301
|
+
return await this.worker.recvHigh();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<ServerMessage, void, void> {
|
|
305
|
+
while (true) {
|
|
306
|
+
const message = await this.recv();
|
|
307
|
+
if (message === null) {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
yield message;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export class StreamLowReceiver {
|
|
316
|
+
private readonly worker: StreamConnectionWorker;
|
|
317
|
+
|
|
318
|
+
constructor(worker: StreamConnectionWorker) {
|
|
319
|
+
this.worker = worker;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async recv(): Promise<ServerMessage | null> {
|
|
323
|
+
return await this.worker.recvLow();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<ServerMessage, void, void> {
|
|
327
|
+
while (true) {
|
|
328
|
+
const message = await this.recv();
|
|
329
|
+
if (message === null) {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
yield message;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export class StreamReceiver {
|
|
338
|
+
private readonly worker: StreamConnectionWorker;
|
|
339
|
+
|
|
340
|
+
constructor(worker: StreamConnectionWorker) {
|
|
341
|
+
this.worker = worker;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async recv(): Promise<ServerMessage | null> {
|
|
345
|
+
return await this.worker.recv();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<ServerMessage, void, void> {
|
|
349
|
+
while (true) {
|
|
350
|
+
const message = await this.recv();
|
|
351
|
+
if (message === null) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
yield message;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export class StreamSender {
|
|
360
|
+
private readonly worker: StreamConnectionWorker;
|
|
361
|
+
|
|
362
|
+
constructor(worker: StreamConnectionWorker) {
|
|
363
|
+
this.worker = worker;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
send(message: ClientMessage): void {
|
|
367
|
+
this.worker.enqueue(message);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
ping(client_time_ms: number): void {
|
|
371
|
+
this.send({
|
|
372
|
+
type: "ping",
|
|
373
|
+
client_time_ms,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
updateStrategy(strategy: StrategyConfigMsg): void {
|
|
378
|
+
this.send({
|
|
379
|
+
type: "update_strategy",
|
|
380
|
+
strategy,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
closePosition(selector: PositionSelectorInput): void {
|
|
385
|
+
this.send(closeMessage(normalizePositionSelector(selector)));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
closeById(positionId: number): void {
|
|
389
|
+
this.closePosition({ position_id: positionId });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
requestExitSignal(
|
|
393
|
+
selector: PositionSelectorInput,
|
|
394
|
+
slippage_bps?: number,
|
|
395
|
+
): void {
|
|
396
|
+
this.send(
|
|
397
|
+
requestExitSignalMessage(normalizePositionSelector(selector), slippage_bps),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
requestExitSignalById(positionId: number, slippage_bps?: number): void {
|
|
402
|
+
this.requestExitSignal({ position_id: positionId }, slippage_bps);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function closeMessage(selector: PositionSelector): ClientMessage {
|
|
407
|
+
if ("token_account" in selector) {
|
|
408
|
+
return {
|
|
409
|
+
type: "close_position",
|
|
410
|
+
token_account: selector.token_account,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
type: "close_position",
|
|
416
|
+
position_id: selector.position_id,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function requestExitSignalMessage(
|
|
421
|
+
selector: PositionSelector,
|
|
422
|
+
slippageBps?: number,
|
|
423
|
+
): ClientMessage {
|
|
424
|
+
if ("token_account" in selector) {
|
|
425
|
+
return {
|
|
426
|
+
type: "request_exit_signal",
|
|
427
|
+
token_account: selector.token_account,
|
|
428
|
+
slippage_bps: slippageBps,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
type: "request_exit_signal",
|
|
434
|
+
position_id: selector.position_id,
|
|
435
|
+
slippage_bps: slippageBps,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizePositionSelector(selector: PositionSelectorInput): PositionSelector {
|
|
440
|
+
if (typeof selector === "string") {
|
|
441
|
+
return {
|
|
442
|
+
token_account: selector,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (typeof selector === "number") {
|
|
447
|
+
return {
|
|
448
|
+
position_id: selector,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if ("token_account" in selector && typeof selector.token_account === "string") {
|
|
453
|
+
return {
|
|
454
|
+
token_account: selector.token_account,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if ("position_id" in selector && typeof selector.position_id === "number") {
|
|
459
|
+
return {
|
|
460
|
+
position_id: selector.position_id,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if ("tokenAccount" in selector && typeof selector.tokenAccount === "string") {
|
|
465
|
+
return {
|
|
466
|
+
token_account: selector.tokenAccount,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if ("positionId" in selector && typeof selector.positionId === "number") {
|
|
471
|
+
return {
|
|
472
|
+
position_id: selector.positionId,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
throw StreamClientError.protocol(
|
|
477
|
+
"position selector must be token account string or position id number",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
type SessionOutcome = "graceful_shutdown" | "reconnect";
|
|
482
|
+
|
|
483
|
+
type InboundMode = "combined" | "lanes";
|
|
484
|
+
|
|
485
|
+
interface StreamConnectionWorkerOptions {
|
|
486
|
+
inboundMode?: InboundMode;
|
|
487
|
+
lowPriorityCapacity?: number;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
class StreamConnectionWorker {
|
|
491
|
+
private readonly endpoint: string;
|
|
492
|
+
private readonly apiKey: string;
|
|
493
|
+
private readonly configure: StreamConfigure;
|
|
494
|
+
|
|
495
|
+
private readonly inboundMode: InboundMode;
|
|
496
|
+
private readonly inboundCombined?: AsyncQueue<ServerMessage>;
|
|
497
|
+
private readonly inboundHigh?: AsyncQueue<ServerMessage>;
|
|
498
|
+
private readonly inboundLow?: AsyncQueue<ServerMessage>;
|
|
499
|
+
|
|
500
|
+
private readonly outbound = new AsyncQueue<ClientMessage>();
|
|
501
|
+
|
|
502
|
+
private currentSocket: WebSocket | null = null;
|
|
503
|
+
private stopped = false;
|
|
504
|
+
|
|
505
|
+
private readonly readyPromise: Promise<void>;
|
|
506
|
+
private readySettled = false;
|
|
507
|
+
private resolveReady!: () => void;
|
|
508
|
+
private rejectReady!: (error: StreamClientError) => void;
|
|
509
|
+
|
|
510
|
+
constructor(
|
|
511
|
+
endpoint: string,
|
|
512
|
+
apiKey: string,
|
|
513
|
+
configure: StreamConfigure,
|
|
514
|
+
options: StreamConnectionWorkerOptions = {},
|
|
515
|
+
) {
|
|
516
|
+
this.endpoint = endpoint;
|
|
517
|
+
this.apiKey = apiKey;
|
|
518
|
+
this.configure = configure;
|
|
519
|
+
|
|
520
|
+
this.inboundMode = options.inboundMode ?? "combined";
|
|
521
|
+
if (this.inboundMode === "combined") {
|
|
522
|
+
this.inboundCombined = new AsyncQueue<ServerMessage>();
|
|
523
|
+
} else {
|
|
524
|
+
this.inboundHigh = new AsyncQueue<ServerMessage>();
|
|
525
|
+
this.inboundLow = new AsyncQueue<ServerMessage>({
|
|
526
|
+
maxLen: options.lowPriorityCapacity ?? 1024,
|
|
527
|
+
dropOldest: true,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
532
|
+
this.resolveReady = resolve;
|
|
533
|
+
this.rejectReady = (error: StreamClientError) => {
|
|
534
|
+
reject(error);
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
void this.run();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async waitReady(): Promise<void> {
|
|
542
|
+
return await this.readyPromise;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
enqueue(message: ClientMessage): void {
|
|
546
|
+
if (this.stopped) {
|
|
547
|
+
throw StreamClientError.sendQueueClosed();
|
|
548
|
+
}
|
|
549
|
+
this.outbound.push(message);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async recv(): Promise<ServerMessage | null> {
|
|
553
|
+
if (this.inboundCombined === undefined) {
|
|
554
|
+
throw StreamClientError.protocol(
|
|
555
|
+
"recv() is not available for lane-mode connections; use StreamConnectionLanes receivers",
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return await this.inboundCombined.shift();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async recvHigh(): Promise<ServerMessage | null> {
|
|
562
|
+
if (this.inboundHigh === undefined) {
|
|
563
|
+
throw StreamClientError.protocol(
|
|
564
|
+
"recvHigh() is only available for lane-mode connections",
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
return await this.inboundHigh.shift();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async recvLow(): Promise<ServerMessage | null> {
|
|
571
|
+
if (this.inboundLow === undefined) {
|
|
572
|
+
throw StreamClientError.protocol(
|
|
573
|
+
"recvLow() is only available for lane-mode connections",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return await this.inboundLow.shift();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
close(): void {
|
|
580
|
+
if (this.stopped) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
this.stopped = true;
|
|
585
|
+
this.outbound.close();
|
|
586
|
+
if (this.currentSocket !== null) {
|
|
587
|
+
safeClose(this.currentSocket);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private async run(): Promise<void> {
|
|
592
|
+
let backoffMs = MIN_RECONNECT_BACKOFF_MS;
|
|
593
|
+
|
|
594
|
+
while (!this.stopped) {
|
|
595
|
+
try {
|
|
596
|
+
const outcome = await this.runConnectedSession();
|
|
597
|
+
if (outcome === "graceful_shutdown") {
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
backoffMs = MIN_RECONNECT_BACKOFF_MS;
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const mapped = asStreamClientError(error);
|
|
603
|
+
if (!this.readySettled) {
|
|
604
|
+
this.setReadyError(mapped);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (this.stopped) {
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const shouldContinue = await sleepWithStop(backoffMs, () => this.stopped);
|
|
614
|
+
if (!shouldContinue) {
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
backoffMs = Math.min(backoffMs * 2, MAX_RECONNECT_BACKOFF_MS);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (!this.readySettled) {
|
|
622
|
+
this.setReadyError(StreamClientError.sendQueueClosed());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
this.stopped = true;
|
|
626
|
+
this.outbound.close();
|
|
627
|
+
this.closeInbound();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async runConnectedSession(): Promise<SessionOutcome> {
|
|
631
|
+
const { socket, frames } = await openSocket(this.endpoint, this.apiKey);
|
|
632
|
+
this.currentSocket = socket;
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
const firstServerMessage = await recvServerMessageBeforeConfigure(socket, frames);
|
|
636
|
+
if (firstServerMessage.type !== "hello_ok") {
|
|
637
|
+
throw StreamClientError.protocol(
|
|
638
|
+
"expected first server message to be hello_ok",
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
this.pushInbound(firstServerMessage);
|
|
642
|
+
|
|
643
|
+
const configureMessage: ClientMessage = {
|
|
644
|
+
type: "configure",
|
|
645
|
+
wallet_pubkeys: [...this.configure.wallet_pubkeys],
|
|
646
|
+
strategy: { ...this.configure.strategy },
|
|
647
|
+
};
|
|
648
|
+
await sendClientMessage(socket, configureMessage);
|
|
649
|
+
|
|
650
|
+
const configuredMessage = await recvServerMessageAfterConfigure(socket, frames);
|
|
651
|
+
this.pushInbound(configuredMessage);
|
|
652
|
+
|
|
653
|
+
if (!this.readySettled) {
|
|
654
|
+
this.readySettled = true;
|
|
655
|
+
this.resolveReady();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
while (!this.stopped) {
|
|
659
|
+
const nextOutbound = this.outbound.shiftNow();
|
|
660
|
+
if (nextOutbound !== undefined) {
|
|
661
|
+
try {
|
|
662
|
+
await sendClientMessage(socket, nextOutbound);
|
|
663
|
+
continue;
|
|
664
|
+
} catch {
|
|
665
|
+
return "reconnect";
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const frame = frames.shiftNow();
|
|
670
|
+
if (frame !== undefined) {
|
|
671
|
+
const outcome = await this.handleFrame(socket, frame);
|
|
672
|
+
if (outcome === "reconnect") {
|
|
673
|
+
return "reconnect";
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const outboundWait = this.outbound.shiftCancelable();
|
|
679
|
+
const frameWait = frames.waitNextCancelable();
|
|
680
|
+
const next = await Promise.race([
|
|
681
|
+
outboundWait.promise.then((message) => ({
|
|
682
|
+
source: "outbound" as const,
|
|
683
|
+
message,
|
|
684
|
+
})),
|
|
685
|
+
frameWait.promise.then((nextFrame) => ({
|
|
686
|
+
source: "frame" as const,
|
|
687
|
+
frame: nextFrame,
|
|
688
|
+
})),
|
|
689
|
+
]);
|
|
690
|
+
|
|
691
|
+
if (next.source === "outbound") {
|
|
692
|
+
frameWait.cancel();
|
|
693
|
+
if (next.message === null) {
|
|
694
|
+
return "graceful_shutdown";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
await sendClientMessage(socket, next.message);
|
|
699
|
+
continue;
|
|
700
|
+
} catch {
|
|
701
|
+
return "reconnect";
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
outboundWait.cancel();
|
|
706
|
+
const outcome = await this.handleFrame(socket, next.frame);
|
|
707
|
+
if (outcome === "reconnect") {
|
|
708
|
+
return "reconnect";
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return "graceful_shutdown";
|
|
713
|
+
} finally {
|
|
714
|
+
this.currentSocket = null;
|
|
715
|
+
safeClose(socket);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async handleFrame(
|
|
720
|
+
socket: WebSocket,
|
|
721
|
+
frame: WsFrame,
|
|
722
|
+
): Promise<SessionOutcome | "continue"> {
|
|
723
|
+
switch (frame.kind) {
|
|
724
|
+
case "text": {
|
|
725
|
+
let parsed: ServerMessage;
|
|
726
|
+
try {
|
|
727
|
+
parsed = serverMessageFromText(frame.text);
|
|
728
|
+
} catch {
|
|
729
|
+
return "reconnect";
|
|
730
|
+
}
|
|
731
|
+
this.pushInbound(parsed);
|
|
732
|
+
return "continue";
|
|
733
|
+
}
|
|
734
|
+
case "ping": {
|
|
735
|
+
try {
|
|
736
|
+
socket.pong(frame.payload);
|
|
737
|
+
} catch {
|
|
738
|
+
return "reconnect";
|
|
739
|
+
}
|
|
740
|
+
return "continue";
|
|
741
|
+
}
|
|
742
|
+
case "pong": {
|
|
743
|
+
return "continue";
|
|
744
|
+
}
|
|
745
|
+
case "binary":
|
|
746
|
+
case "close":
|
|
747
|
+
case "error": {
|
|
748
|
+
return "reconnect";
|
|
749
|
+
}
|
|
750
|
+
default: {
|
|
751
|
+
const _unreachable: never = frame;
|
|
752
|
+
return _unreachable;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private setReadyError(error: StreamClientError): void {
|
|
758
|
+
if (this.readySettled) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
this.readySettled = true;
|
|
763
|
+
this.rejectReady(error);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private pushInbound(message: ServerMessage): void {
|
|
767
|
+
if (this.inboundMode === "combined") {
|
|
768
|
+
this.inboundCombined!.push(message);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (message.type === "pnl_update") {
|
|
773
|
+
this.inboundLow!.push(message);
|
|
774
|
+
} else {
|
|
775
|
+
this.inboundHigh!.push(message);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private closeInbound(): void {
|
|
780
|
+
this.inboundCombined?.close();
|
|
781
|
+
this.inboundHigh?.close();
|
|
782
|
+
this.inboundLow?.close();
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function openSocket(
|
|
787
|
+
url: string,
|
|
788
|
+
apiKey: string,
|
|
789
|
+
): Promise<{ socket: WebSocket; frames: WebSocketFrameQueue }> {
|
|
790
|
+
return await new Promise<{ socket: WebSocket; frames: WebSocketFrameQueue }>(
|
|
791
|
+
(resolve, reject) => {
|
|
792
|
+
let settled = false;
|
|
793
|
+
|
|
794
|
+
let socket: WebSocket;
|
|
795
|
+
try {
|
|
796
|
+
socket = new WebSocket(url, {
|
|
797
|
+
headers: {
|
|
798
|
+
"x-api-key": apiKey,
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
} catch (error) {
|
|
802
|
+
reject(StreamClientError.invalidApiKeyHeader(error));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Attach frame listeners before waiting for `open` so we never miss an
|
|
807
|
+
// immediate server hello frame on low-latency links.
|
|
808
|
+
const frames = new WebSocketFrameQueue(socket);
|
|
809
|
+
|
|
810
|
+
const onOpen = (): void => {
|
|
811
|
+
if (settled) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
settled = true;
|
|
815
|
+
cleanup();
|
|
816
|
+
resolve({ socket, frames });
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const onError = (error: Error): void => {
|
|
820
|
+
if (settled) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
settled = true;
|
|
824
|
+
cleanup();
|
|
825
|
+
reject(StreamClientError.websocket(error));
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const onClose = (): void => {
|
|
829
|
+
if (settled) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
settled = true;
|
|
833
|
+
cleanup();
|
|
834
|
+
reject(StreamClientError.protocol("socket closed before open"));
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const cleanup = (): void => {
|
|
838
|
+
socket.off("open", onOpen);
|
|
839
|
+
socket.off("error", onError);
|
|
840
|
+
socket.off("close", onClose);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
socket.on("open", onOpen);
|
|
844
|
+
socket.on("error", onError);
|
|
845
|
+
socket.on("close", onClose);
|
|
846
|
+
},
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function recvServerMessageBeforeConfigure(
|
|
851
|
+
socket: WebSocket,
|
|
852
|
+
frames: WebSocketFrameQueue,
|
|
853
|
+
): Promise<ServerMessage> {
|
|
854
|
+
while (true) {
|
|
855
|
+
const frame = await frames.waitNext();
|
|
856
|
+
switch (frame.kind) {
|
|
857
|
+
case "text": {
|
|
858
|
+
try {
|
|
859
|
+
return serverMessageFromText(frame.text);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
throw StreamClientError.json(error);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
case "ping": {
|
|
865
|
+
socket.pong(frame.payload);
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
case "pong": {
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
case "close": {
|
|
872
|
+
throw StreamClientError.protocol("socket closed before hello_ok");
|
|
873
|
+
}
|
|
874
|
+
case "error": {
|
|
875
|
+
throw StreamClientError.websocket(frame.error);
|
|
876
|
+
}
|
|
877
|
+
case "binary": {
|
|
878
|
+
throw StreamClientError.protocol(
|
|
879
|
+
"received non-text frame before hello_ok",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
default: {
|
|
883
|
+
const _unreachable: never = frame;
|
|
884
|
+
throw _unreachable;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function recvServerMessageAfterConfigure(
|
|
891
|
+
socket: WebSocket,
|
|
892
|
+
frames: WebSocketFrameQueue,
|
|
893
|
+
): Promise<ServerMessage> {
|
|
894
|
+
while (true) {
|
|
895
|
+
const frame = await frames.waitNext();
|
|
896
|
+
switch (frame.kind) {
|
|
897
|
+
case "text": {
|
|
898
|
+
try {
|
|
899
|
+
return serverMessageFromText(frame.text);
|
|
900
|
+
} catch (error) {
|
|
901
|
+
throw StreamClientError.json(error);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
case "ping": {
|
|
905
|
+
socket.pong(frame.payload);
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
case "pong": {
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
case "close": {
|
|
912
|
+
throw StreamClientError.protocol(
|
|
913
|
+
"socket closed before configure acknowledgement",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
case "error": {
|
|
917
|
+
throw StreamClientError.websocket(frame.error);
|
|
918
|
+
}
|
|
919
|
+
case "binary": {
|
|
920
|
+
throw StreamClientError.protocol(
|
|
921
|
+
"received non-text frame before configure acknowledgement",
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
default: {
|
|
925
|
+
const _unreachable: never = frame;
|
|
926
|
+
throw _unreachable;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function sendClientMessage(
|
|
933
|
+
socket: WebSocket,
|
|
934
|
+
message: ClientMessage,
|
|
935
|
+
): Promise<void> {
|
|
936
|
+
const text = clientMessageToText(message);
|
|
937
|
+
|
|
938
|
+
await new Promise<void>((resolve, reject) => {
|
|
939
|
+
socket.send(text, (error) => {
|
|
940
|
+
if (error) {
|
|
941
|
+
reject(StreamClientError.websocket(error));
|
|
942
|
+
} else {
|
|
943
|
+
resolve();
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function safeClose(socket: WebSocket): void {
|
|
950
|
+
if (
|
|
951
|
+
socket.readyState === WebSocket.CLOSING ||
|
|
952
|
+
socket.readyState === WebSocket.CLOSED
|
|
953
|
+
) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
socket.close();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
type WsFrame =
|
|
960
|
+
| { kind: "text"; text: string }
|
|
961
|
+
| { kind: "binary" }
|
|
962
|
+
| { kind: "ping"; payload: Buffer }
|
|
963
|
+
| { kind: "pong"; payload: Buffer }
|
|
964
|
+
| { kind: "close"; code: number; reason: string }
|
|
965
|
+
| { kind: "error"; error: Error };
|
|
966
|
+
|
|
967
|
+
class Deque<T> {
|
|
968
|
+
private buffer: Array<T | undefined>;
|
|
969
|
+
private head = 0;
|
|
970
|
+
private len = 0;
|
|
971
|
+
private mask: number;
|
|
972
|
+
|
|
973
|
+
constructor(initialCapacity = 16) {
|
|
974
|
+
const cap = nextPowerOfTwo(Math.max(2, initialCapacity));
|
|
975
|
+
this.buffer = new Array<T | undefined>(cap);
|
|
976
|
+
this.mask = cap - 1;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
get length(): number {
|
|
980
|
+
return this.len;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
push(value: T): void {
|
|
984
|
+
if (this.len === this.buffer.length) {
|
|
985
|
+
this.grow();
|
|
986
|
+
}
|
|
987
|
+
const index = (this.head + this.len) & this.mask;
|
|
988
|
+
this.buffer[index] = value;
|
|
989
|
+
this.len += 1;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
unshift(value: T): void {
|
|
993
|
+
if (this.len === this.buffer.length) {
|
|
994
|
+
this.grow();
|
|
995
|
+
}
|
|
996
|
+
this.head = (this.head - 1) & this.mask;
|
|
997
|
+
this.buffer[this.head] = value;
|
|
998
|
+
this.len += 1;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
shift(): T | undefined {
|
|
1002
|
+
if (this.len === 0) {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
const value = this.buffer[this.head];
|
|
1006
|
+
this.buffer[this.head] = undefined;
|
|
1007
|
+
this.head = (this.head + 1) & this.mask;
|
|
1008
|
+
this.len -= 1;
|
|
1009
|
+
return value;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private grow(): void {
|
|
1013
|
+
const old = this.buffer;
|
|
1014
|
+
const newCap = old.length * 2;
|
|
1015
|
+
const next = new Array<T | undefined>(newCap);
|
|
1016
|
+
for (let i = 0; i < this.len; i += 1) {
|
|
1017
|
+
next[i] = old[(this.head + i) & this.mask];
|
|
1018
|
+
}
|
|
1019
|
+
this.buffer = next;
|
|
1020
|
+
this.head = 0;
|
|
1021
|
+
this.mask = newCap - 1;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function nextPowerOfTwo(value: number): number {
|
|
1026
|
+
let v = Math.max(2, Math.floor(value));
|
|
1027
|
+
v -= 1;
|
|
1028
|
+
v |= v >> 1;
|
|
1029
|
+
v |= v >> 2;
|
|
1030
|
+
v |= v >> 4;
|
|
1031
|
+
v |= v >> 8;
|
|
1032
|
+
v |= v >> 16;
|
|
1033
|
+
return v + 1;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
class WebSocketFrameQueue {
|
|
1037
|
+
private readonly frames = new Deque<WsFrame>();
|
|
1038
|
+
private readonly waiters: Array<{
|
|
1039
|
+
canceled: boolean;
|
|
1040
|
+
hasValue: boolean;
|
|
1041
|
+
value: WsFrame | null;
|
|
1042
|
+
settled: boolean;
|
|
1043
|
+
resolve: (frame: WsFrame) => void;
|
|
1044
|
+
}> = [];
|
|
1045
|
+
|
|
1046
|
+
constructor(socket: WebSocket) {
|
|
1047
|
+
socket.on("message", (data, isBinary) => {
|
|
1048
|
+
if (isBinary) {
|
|
1049
|
+
this.push({ kind: "binary" });
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const text = rawDataToString(data);
|
|
1054
|
+
this.push({ kind: "text", text });
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
socket.on("ping", (payload) => {
|
|
1058
|
+
this.push({
|
|
1059
|
+
kind: "ping",
|
|
1060
|
+
payload: rawDataToBuffer(payload),
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
socket.on("pong", (payload) => {
|
|
1065
|
+
this.push({
|
|
1066
|
+
kind: "pong",
|
|
1067
|
+
payload: rawDataToBuffer(payload),
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
socket.on("close", (code, reason) => {
|
|
1072
|
+
this.push({
|
|
1073
|
+
kind: "close",
|
|
1074
|
+
code,
|
|
1075
|
+
reason: reason.toString("utf8"),
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
socket.on("error", (error) => {
|
|
1080
|
+
this.push({ kind: "error", error });
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
shiftNow(): WsFrame | undefined {
|
|
1085
|
+
return this.frames.shift();
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
waitNextCancelable(): {
|
|
1089
|
+
promise: Promise<WsFrame>;
|
|
1090
|
+
cancel: () => void;
|
|
1091
|
+
} {
|
|
1092
|
+
const next = this.shiftNow();
|
|
1093
|
+
if (next !== undefined) {
|
|
1094
|
+
return {
|
|
1095
|
+
promise: Promise.resolve(next),
|
|
1096
|
+
cancel: () => {},
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
let waiter:
|
|
1101
|
+
| {
|
|
1102
|
+
canceled: boolean;
|
|
1103
|
+
hasValue: boolean;
|
|
1104
|
+
value: WsFrame | null;
|
|
1105
|
+
settled: boolean;
|
|
1106
|
+
resolve: (frame: WsFrame) => void;
|
|
1107
|
+
}
|
|
1108
|
+
| undefined;
|
|
1109
|
+
const promise = new Promise<WsFrame>((resolve) => {
|
|
1110
|
+
waiter = {
|
|
1111
|
+
canceled: false,
|
|
1112
|
+
hasValue: false,
|
|
1113
|
+
value: null,
|
|
1114
|
+
settled: false,
|
|
1115
|
+
resolve,
|
|
1116
|
+
};
|
|
1117
|
+
this.waiters.push(waiter);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
const cancel = (): void => {
|
|
1121
|
+
if (waiter === undefined || waiter.canceled) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
waiter.canceled = true;
|
|
1125
|
+
if (!waiter.settled) {
|
|
1126
|
+
const index = this.waiters.indexOf(waiter);
|
|
1127
|
+
if (index >= 0) {
|
|
1128
|
+
this.waiters.splice(index, 1);
|
|
1129
|
+
}
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (waiter.hasValue && waiter.value !== null) {
|
|
1133
|
+
this.frames.unshift(waiter.value);
|
|
1134
|
+
waiter.hasValue = false;
|
|
1135
|
+
waiter.value = null;
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
return { promise, cancel };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async waitNext(): Promise<WsFrame> {
|
|
1143
|
+
const { promise } = this.waitNextCancelable();
|
|
1144
|
+
return await promise;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private push(frame: WsFrame): void {
|
|
1148
|
+
const waiter = this.waiters.shift();
|
|
1149
|
+
if (waiter !== undefined) {
|
|
1150
|
+
waiter.settled = true;
|
|
1151
|
+
waiter.hasValue = true;
|
|
1152
|
+
waiter.value = frame;
|
|
1153
|
+
waiter.resolve(frame);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
this.frames.push(frame);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
class AsyncQueue<T> {
|
|
1162
|
+
private readonly items = new Deque<T>();
|
|
1163
|
+
private readonly waiters: Array<{
|
|
1164
|
+
canceled: boolean;
|
|
1165
|
+
hasValue: boolean;
|
|
1166
|
+
value: T | null;
|
|
1167
|
+
settled: boolean;
|
|
1168
|
+
resolve: (value: T | null) => void;
|
|
1169
|
+
}> = [];
|
|
1170
|
+
private closed = false;
|
|
1171
|
+
|
|
1172
|
+
private readonly maxLen?: number;
|
|
1173
|
+
private readonly dropOldest: boolean;
|
|
1174
|
+
|
|
1175
|
+
constructor(options?: { maxLen?: number; dropOldest?: boolean }) {
|
|
1176
|
+
this.maxLen = options?.maxLen;
|
|
1177
|
+
this.dropOldest = options?.dropOldest ?? false;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
push(item: T): void {
|
|
1181
|
+
if (this.closed) {
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const waiter = this.waiters.shift();
|
|
1186
|
+
if (waiter !== undefined) {
|
|
1187
|
+
waiter.settled = true;
|
|
1188
|
+
waiter.hasValue = true;
|
|
1189
|
+
waiter.value = item;
|
|
1190
|
+
waiter.resolve(item);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (this.maxLen !== undefined && this.items.length >= this.maxLen) {
|
|
1195
|
+
if (this.dropOldest) {
|
|
1196
|
+
this.items.shift();
|
|
1197
|
+
} else {
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
this.items.push(item);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
close(): void {
|
|
1206
|
+
if (this.closed) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
this.closed = true;
|
|
1211
|
+
while (this.waiters.length > 0) {
|
|
1212
|
+
const waiter = this.waiters.shift();
|
|
1213
|
+
if (waiter !== undefined) {
|
|
1214
|
+
waiter.settled = true;
|
|
1215
|
+
waiter.hasValue = true;
|
|
1216
|
+
waiter.value = null;
|
|
1217
|
+
waiter.resolve(null);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
shiftNow(): T | undefined {
|
|
1223
|
+
return this.items.shift();
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
shiftCancelable(): {
|
|
1227
|
+
promise: Promise<T | null>;
|
|
1228
|
+
cancel: () => void;
|
|
1229
|
+
} {
|
|
1230
|
+
const next = this.shiftNow();
|
|
1231
|
+
if (next !== undefined) {
|
|
1232
|
+
return {
|
|
1233
|
+
promise: Promise.resolve(next),
|
|
1234
|
+
cancel: () => {},
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (this.closed) {
|
|
1239
|
+
return {
|
|
1240
|
+
promise: Promise.resolve(null),
|
|
1241
|
+
cancel: () => {},
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
let waiter:
|
|
1246
|
+
| {
|
|
1247
|
+
canceled: boolean;
|
|
1248
|
+
hasValue: boolean;
|
|
1249
|
+
value: T | null;
|
|
1250
|
+
settled: boolean;
|
|
1251
|
+
resolve: (value: T | null) => void;
|
|
1252
|
+
}
|
|
1253
|
+
| undefined;
|
|
1254
|
+
const promise = new Promise<T | null>((resolve) => {
|
|
1255
|
+
waiter = {
|
|
1256
|
+
canceled: false,
|
|
1257
|
+
hasValue: false,
|
|
1258
|
+
value: null,
|
|
1259
|
+
settled: false,
|
|
1260
|
+
resolve,
|
|
1261
|
+
};
|
|
1262
|
+
this.waiters.push(waiter);
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
const cancel = (): void => {
|
|
1266
|
+
if (waiter === undefined || waiter.canceled) {
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
waiter.canceled = true;
|
|
1270
|
+
if (!waiter.settled) {
|
|
1271
|
+
const index = this.waiters.indexOf(waiter);
|
|
1272
|
+
if (index >= 0) {
|
|
1273
|
+
this.waiters.splice(index, 1);
|
|
1274
|
+
}
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
if (waiter.hasValue && waiter.value !== null && !this.closed) {
|
|
1278
|
+
this.items.unshift(waiter.value);
|
|
1279
|
+
waiter.hasValue = false;
|
|
1280
|
+
waiter.value = null;
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
return { promise, cancel };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async shift(): Promise<T | null> {
|
|
1288
|
+
const { promise } = this.shiftCancelable();
|
|
1289
|
+
return await promise;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function rawDataToString(data: RawData): string {
|
|
1294
|
+
if (typeof data === "string") {
|
|
1295
|
+
return data;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
return rawDataToBuffer(data).toString("utf8");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function rawDataToBuffer(data: RawData): Buffer {
|
|
1302
|
+
if (Buffer.isBuffer(data)) {
|
|
1303
|
+
return data;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (Array.isArray(data)) {
|
|
1307
|
+
return Buffer.concat(data);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const value: unknown = data;
|
|
1311
|
+
|
|
1312
|
+
if (value instanceof ArrayBuffer) {
|
|
1313
|
+
return Buffer.from(value);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (ArrayBuffer.isView(value)) {
|
|
1317
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
throw StreamClientError.protocol("unexpected websocket raw data frame");
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function asStreamClientError(error: unknown): StreamClientError {
|
|
1324
|
+
if (error instanceof StreamClientError) {
|
|
1325
|
+
return error;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return StreamClientError.websocket(error);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function sleepWithStop(
|
|
1332
|
+
ms: number,
|
|
1333
|
+
shouldStop: () => boolean,
|
|
1334
|
+
): Promise<boolean> {
|
|
1335
|
+
const intervalMs = 20;
|
|
1336
|
+
let remaining = ms;
|
|
1337
|
+
|
|
1338
|
+
while (remaining > 0) {
|
|
1339
|
+
if (shouldStop()) {
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const step = Math.min(intervalMs, remaining);
|
|
1344
|
+
await sleep(step);
|
|
1345
|
+
remaining -= step;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return !shouldStop();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function sleep(ms: number): Promise<void> {
|
|
1352
|
+
return new Promise((resolve) => {
|
|
1353
|
+
setTimeout(resolve, ms);
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function stringifyError(error: unknown): string {
|
|
1358
|
+
if (error instanceof Error) {
|
|
1359
|
+
return `${error.name}: ${error.message}`;
|
|
1360
|
+
}
|
|
1361
|
+
return String(error);
|
|
1362
|
+
}
|