sockress-client 0.1.8 → 0.2.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 +29 -0
- package/package.json +2 -1
- package/dist/index.d.ts +0 -111
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -522
- package/dist/index.js.map +0 -1
- package/src/index.ts +0 -700
- package/tsconfig.json +0 -14
package/src/index.ts
DELETED
|
@@ -1,700 +0,0 @@
|
|
|
1
|
-
import { nanoid } from 'nanoid';
|
|
2
|
-
|
|
3
|
-
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
|
4
|
-
|
|
5
|
-
interface SocketRequestPayload {
|
|
6
|
-
type: 'request';
|
|
7
|
-
id: string;
|
|
8
|
-
method: HTTPMethod;
|
|
9
|
-
path: string;
|
|
10
|
-
headers: Record<string, string>;
|
|
11
|
-
query?: Record<string, string | string[]>;
|
|
12
|
-
body?: unknown;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface SocketResponsePayload {
|
|
16
|
-
type: 'response';
|
|
17
|
-
id: string;
|
|
18
|
-
status: number;
|
|
19
|
-
headers?: Record<string, string>;
|
|
20
|
-
body?: unknown;
|
|
21
|
-
cookies?: string[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface SocketErrorPayload {
|
|
25
|
-
type: 'error';
|
|
26
|
-
id?: string;
|
|
27
|
-
message: string;
|
|
28
|
-
code?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface SockressClientOptions {
|
|
32
|
-
baseUrl: string;
|
|
33
|
-
socketPath?: string;
|
|
34
|
-
headers?: Record<string, string>;
|
|
35
|
-
timeout?: number;
|
|
36
|
-
reconnectInterval?: number;
|
|
37
|
-
maxReconnectInterval?: number;
|
|
38
|
-
autoConnect?: boolean;
|
|
39
|
-
preferSocket?: boolean;
|
|
40
|
-
fetchImpl?: typeof fetch;
|
|
41
|
-
wsFactory?: WebSocketFactory;
|
|
42
|
-
credentials?: RequestCredentials;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface SockressClientRequest {
|
|
46
|
-
path: string;
|
|
47
|
-
method?: HTTPMethod;
|
|
48
|
-
headers?: Record<string, string>;
|
|
49
|
-
query?: Record<string, string | number | boolean | Array<string | number | boolean>>;
|
|
50
|
-
body?: unknown;
|
|
51
|
-
timeout?: number;
|
|
52
|
-
signal?: AbortSignal;
|
|
53
|
-
disableHttpFallback?: boolean;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface SockressClientResponse<T = unknown> {
|
|
57
|
-
status: number;
|
|
58
|
-
ok: boolean;
|
|
59
|
-
headers: Record<string, string>;
|
|
60
|
-
body: T;
|
|
61
|
-
json: <R = T>() => R;
|
|
62
|
-
text: () => string;
|
|
63
|
-
raw: () => T;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export type EventMap = {
|
|
67
|
-
open: void;
|
|
68
|
-
close: { code?: number; reason?: string };
|
|
69
|
-
error: unknown;
|
|
70
|
-
reconnect: { attempt: number };
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
type Listener<K extends keyof EventMap> = (payload: EventMap[K]) => void;
|
|
74
|
-
|
|
75
|
-
type WebSocketLike = {
|
|
76
|
-
readyState: number;
|
|
77
|
-
send(data: string): void;
|
|
78
|
-
close(code?: number, reason?: string): void;
|
|
79
|
-
onopen?: ((event: any) => void) | null;
|
|
80
|
-
onmessage?: ((event: any) => void) | null;
|
|
81
|
-
onclose?: ((event: any) => void) | null;
|
|
82
|
-
onerror?: ((event: any) => void) | null;
|
|
83
|
-
addEventListener?(type: string, listener: (event: any) => void): void;
|
|
84
|
-
removeEventListener?(type: string, listener: (event: any) => void): void;
|
|
85
|
-
on?(type: string, listener: (...args: unknown[]) => void): void;
|
|
86
|
-
off?(type: string, listener: (...args: unknown[]) => void): void;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
export type WebSocketFactory = (url: string) => WebSocketLike;
|
|
90
|
-
|
|
91
|
-
interface PendingRequest {
|
|
92
|
-
resolve: (value: SockressClientResponse<any>) => void;
|
|
93
|
-
reject: (reason?: unknown) => void;
|
|
94
|
-
timeout?: ReturnType<typeof setTimeout>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface NormalizedOptions {
|
|
98
|
-
baseUrl: string;
|
|
99
|
-
socketPath: string;
|
|
100
|
-
headers: Record<string, string>;
|
|
101
|
-
timeout: number;
|
|
102
|
-
reconnectInterval: number;
|
|
103
|
-
maxReconnectInterval: number;
|
|
104
|
-
autoConnect: boolean;
|
|
105
|
-
preferSocket: boolean;
|
|
106
|
-
fetchImpl: typeof fetch;
|
|
107
|
-
wsFactory?: WebSocketFactory;
|
|
108
|
-
credentials: RequestCredentials;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export class SockressClient {
|
|
112
|
-
private ws?: WebSocketLike;
|
|
113
|
-
private reconnectAttempts = 0;
|
|
114
|
-
private pending = new Map<string, PendingRequest>();
|
|
115
|
-
private queue: SocketRequestPayload[] = [];
|
|
116
|
-
private listeners: { [K in keyof EventMap]: Set<Listener<K>> } = {
|
|
117
|
-
open: new Set(),
|
|
118
|
-
close: new Set(),
|
|
119
|
-
error: new Set(),
|
|
120
|
-
reconnect: new Set()
|
|
121
|
-
};
|
|
122
|
-
private socketEnabled = true;
|
|
123
|
-
private lifecycleTeardown: Array<() => void> = [];
|
|
124
|
-
private closeRequested = false;
|
|
125
|
-
|
|
126
|
-
constructor(private readonly options: NormalizedOptions) {
|
|
127
|
-
if (options.autoConnect) {
|
|
128
|
-
this.connect().catch(() => {
|
|
129
|
-
// Ignore initial connection failures, HTTP fallback will handle requests.
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
this.registerLifecycleHooks();
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
static create(options: SockressClientOptions): SockressClient {
|
|
136
|
-
return new SockressClient(normalizeOptions(options));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
on<K extends keyof EventMap>(event: K, listener: Listener<K>): () => void {
|
|
140
|
-
this.listeners[event].add(listener as Listener<K>);
|
|
141
|
-
return () => this.off(event, listener);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
off<K extends keyof EventMap>(event: K, listener: Listener<K>): void {
|
|
145
|
-
this.listeners[event].delete(listener as Listener<K>);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
|
|
149
|
-
for (const listener of this.listeners[event]) {
|
|
150
|
-
listener(payload);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async connect(): Promise<void> {
|
|
155
|
-
if (!this.options.wsFactory) {
|
|
156
|
-
this.socketEnabled = false;
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (this.ws && this.ws.readyState === 1) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const socketUrl = buildSocketUrl(this.options.baseUrl, this.options.socketPath);
|
|
163
|
-
this.ws = this.options.wsFactory(socketUrl);
|
|
164
|
-
this.attachSocketHandlers(this.ws);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
private attachSocketHandlers(socket: WebSocketLike): void {
|
|
168
|
-
const handleOpen = () => {
|
|
169
|
-
this.reconnectAttempts = 0;
|
|
170
|
-
this.emit('open', undefined);
|
|
171
|
-
this.flushQueue();
|
|
172
|
-
};
|
|
173
|
-
const handleMessage = (event: unknown) => {
|
|
174
|
-
const data = resolveEventData(event);
|
|
175
|
-
if (data) {
|
|
176
|
-
this.handleSocketMessage(data);
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
const handleError = (event: unknown) => {
|
|
180
|
-
this.emit('error', event);
|
|
181
|
-
this.rejectAllPending(new Error('Socket error'));
|
|
182
|
-
};
|
|
183
|
-
const handleClose = (details?: { code?: number; reason?: string }) => {
|
|
184
|
-
this.emit('close', { code: details?.code, reason: details?.reason });
|
|
185
|
-
this.rejectAllPending(new Error('Socket closed'));
|
|
186
|
-
this.scheduleReconnect();
|
|
187
|
-
};
|
|
188
|
-
if (typeof socket.addEventListener === 'function') {
|
|
189
|
-
socket.addEventListener('open', () => handleOpen());
|
|
190
|
-
socket.addEventListener('message', (event) => handleMessage(event));
|
|
191
|
-
socket.addEventListener('error', (event) => handleError(event));
|
|
192
|
-
socket.addEventListener('close', (event) => handleClose(extractCloseDetails(event)));
|
|
193
|
-
} else if (typeof socket.on === 'function') {
|
|
194
|
-
socket.on('open', handleOpen);
|
|
195
|
-
socket.on('message', (data: unknown) => handleMessage({ data }));
|
|
196
|
-
socket.on('error', handleError);
|
|
197
|
-
socket.on('close', (...args: unknown[]) => {
|
|
198
|
-
const [code, reason] = args;
|
|
199
|
-
handleClose({
|
|
200
|
-
code: typeof code === 'number' ? code : undefined,
|
|
201
|
-
reason: typeof reason === 'string' ? reason : typeof reason === 'object' && reason ? `${reason}` : undefined
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
} else {
|
|
205
|
-
socket.onopen = () => handleOpen();
|
|
206
|
-
socket.onmessage = (event) => handleMessage(event);
|
|
207
|
-
socket.onerror = (event) => handleError(event);
|
|
208
|
-
socket.onclose = (event) => handleClose(extractCloseDetails(event));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
private flushQueue(): void {
|
|
213
|
-
if (!this.ws || this.ws.readyState !== 1) return;
|
|
214
|
-
while (this.queue.length) {
|
|
215
|
-
const payload = this.queue.shift();
|
|
216
|
-
if (!payload) continue;
|
|
217
|
-
this.ws.send(JSON.stringify(payload));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private scheduleReconnect(): void {
|
|
222
|
-
if (!this.socketEnabled || !this.options.wsFactory) {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
this.reconnectAttempts += 1;
|
|
226
|
-
const delay = Math.min(
|
|
227
|
-
this.options.reconnectInterval * this.reconnectAttempts,
|
|
228
|
-
this.options.maxReconnectInterval
|
|
229
|
-
);
|
|
230
|
-
setTimeout(() => {
|
|
231
|
-
this.emit('reconnect', { attempt: this.reconnectAttempts });
|
|
232
|
-
this.connect().catch(() => {
|
|
233
|
-
// keep trying silently
|
|
234
|
-
});
|
|
235
|
-
}, delay);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private handleSocketMessage(raw: string): void {
|
|
239
|
-
try {
|
|
240
|
-
const payload = JSON.parse(raw) as SocketResponsePayload | SocketErrorPayload;
|
|
241
|
-
if (payload.type === 'error') {
|
|
242
|
-
const pending = payload.id ? this.pending.get(payload.id) : null;
|
|
243
|
-
const error = new Error(payload.message);
|
|
244
|
-
if (pending) {
|
|
245
|
-
this.clearPending(payload.id!);
|
|
246
|
-
pending.reject(error);
|
|
247
|
-
} else {
|
|
248
|
-
this.emit('error', error);
|
|
249
|
-
}
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const pending = this.pending.get(payload.id);
|
|
253
|
-
if (!pending) {
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
this.clearPending(payload.id);
|
|
257
|
-
const response = createClientResponse(payload);
|
|
258
|
-
this.applyCookies(payload.cookies);
|
|
259
|
-
pending.resolve(response);
|
|
260
|
-
} catch (error) {
|
|
261
|
-
this.emit('error', error);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private applyCookies(cookies?: string[]): void {
|
|
266
|
-
if (!cookies || typeof document === 'undefined') return;
|
|
267
|
-
for (const cookie of cookies) {
|
|
268
|
-
document.cookie = cookie;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
private clearPending(id: string): void {
|
|
273
|
-
const pending = this.pending.get(id);
|
|
274
|
-
if (pending?.timeout) {
|
|
275
|
-
clearTimeout(pending.timeout);
|
|
276
|
-
}
|
|
277
|
-
this.pending.delete(id);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
private rejectAllPending(error: Error): void {
|
|
281
|
-
for (const [id, pending] of this.pending.entries()) {
|
|
282
|
-
this.clearPending(id);
|
|
283
|
-
pending.reject(error);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
private registerLifecycleHooks(): void {
|
|
288
|
-
const boundClose = () => this.close();
|
|
289
|
-
if (typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
290
|
-
const handleSignal = () => boundClose();
|
|
291
|
-
process.once('beforeExit', handleSignal);
|
|
292
|
-
process.once('SIGINT', handleSignal);
|
|
293
|
-
process.once('SIGTERM', handleSignal);
|
|
294
|
-
this.lifecycleTeardown.push(() => {
|
|
295
|
-
process.off('beforeExit', handleSignal);
|
|
296
|
-
process.off('SIGINT', handleSignal);
|
|
297
|
-
process.off('SIGTERM', handleSignal);
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
301
|
-
const handleUnload = () => boundClose();
|
|
302
|
-
window.addEventListener('beforeunload', handleUnload);
|
|
303
|
-
this.lifecycleTeardown.push(() => window.removeEventListener('beforeunload', handleUnload));
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private canUseSocket(): boolean {
|
|
308
|
-
return Boolean(this.options.wsFactory && this.socketEnabled && this.ws && this.ws.readyState === 1);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async request<T = unknown>(options: SockressClientRequest): Promise<SockressClientResponse<T>> {
|
|
312
|
-
const method = (options.method || 'GET').toUpperCase() as HTTPMethod;
|
|
313
|
-
const path = normalizePath(options.path);
|
|
314
|
-
const headers = { ...this.options.headers, ...(options.headers ?? {}) };
|
|
315
|
-
const query = options.query ? normalizeQuery(options.query) : undefined;
|
|
316
|
-
const timeout = options.timeout ?? this.options.timeout;
|
|
317
|
-
|
|
318
|
-
if (this.options.preferSocket && this.socketEnabled) {
|
|
319
|
-
try {
|
|
320
|
-
return await this.sendViaSocket<T>({ method, path, headers, query, body: options.body, timeout });
|
|
321
|
-
} catch (error) {
|
|
322
|
-
if (options.disableHttpFallback) {
|
|
323
|
-
throw error;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return this.sendViaHttp<T>({ method, path, headers, query, body: options.body, signal: options.signal });
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
get<T = unknown>(
|
|
331
|
-
path: string,
|
|
332
|
-
options?: Omit<SockressClientRequest, 'path' | 'method'>
|
|
333
|
-
): Promise<SockressClientResponse<T>> {
|
|
334
|
-
return this.request<T>({ ...(options ?? {}), path, method: 'GET' });
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
post<T = unknown>(
|
|
338
|
-
path: string,
|
|
339
|
-
options?: Omit<SockressClientRequest, 'path' | 'method'>
|
|
340
|
-
): Promise<SockressClientResponse<T>> {
|
|
341
|
-
return this.request<T>({ ...(options ?? {}), path, method: 'POST' });
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
put<T = unknown>(
|
|
345
|
-
path: string,
|
|
346
|
-
options?: Omit<SockressClientRequest, 'path' | 'method'>
|
|
347
|
-
): Promise<SockressClientResponse<T>> {
|
|
348
|
-
return this.request<T>({ ...(options ?? {}), path, method: 'PUT' });
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
patch<T = unknown>(
|
|
352
|
-
path: string,
|
|
353
|
-
options?: Omit<SockressClientRequest, 'path' | 'method'>
|
|
354
|
-
): Promise<SockressClientResponse<T>> {
|
|
355
|
-
return this.request<T>({ ...(options ?? {}), path, method: 'PATCH' });
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
delete<T = unknown>(
|
|
359
|
-
path: string,
|
|
360
|
-
options?: Omit<SockressClientRequest, 'path' | 'method'>
|
|
361
|
-
): Promise<SockressClientResponse<T>> {
|
|
362
|
-
return this.request<T>({ ...(options ?? {}), path, method: 'DELETE' });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
private async sendViaSocket<T>(input: {
|
|
366
|
-
method: HTTPMethod;
|
|
367
|
-
path: string;
|
|
368
|
-
headers: Record<string, string>;
|
|
369
|
-
query?: Record<string, string | string[]>;
|
|
370
|
-
body?: unknown;
|
|
371
|
-
timeout: number;
|
|
372
|
-
}): Promise<SockressClientResponse<T>> {
|
|
373
|
-
if (!this.options.wsFactory) {
|
|
374
|
-
this.socketEnabled = false;
|
|
375
|
-
throw new Error('Socket transport is unavailable');
|
|
376
|
-
}
|
|
377
|
-
await this.connect();
|
|
378
|
-
const id = nanoid();
|
|
379
|
-
const serializedBody = await serializeBodyForSocket(input.body);
|
|
380
|
-
const payload: SocketRequestPayload = {
|
|
381
|
-
type: 'request',
|
|
382
|
-
id,
|
|
383
|
-
method: input.method,
|
|
384
|
-
path: input.path,
|
|
385
|
-
headers: input.headers,
|
|
386
|
-
query: input.query,
|
|
387
|
-
body: serializedBody
|
|
388
|
-
};
|
|
389
|
-
const responsePromise = new Promise<SockressClientResponse<T>>((resolve, reject) => {
|
|
390
|
-
const timeout = setTimeout(() => {
|
|
391
|
-
this.clearPending(id);
|
|
392
|
-
reject(new Error('Socket request timed out'));
|
|
393
|
-
}, input.timeout);
|
|
394
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
395
|
-
});
|
|
396
|
-
const serialized = JSON.stringify(payload);
|
|
397
|
-
if (this.canUseSocket()) {
|
|
398
|
-
this.ws!.send(serialized);
|
|
399
|
-
} else {
|
|
400
|
-
this.queue.push(payload);
|
|
401
|
-
}
|
|
402
|
-
return responsePromise;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
private async sendViaHttp<T>(input: {
|
|
406
|
-
method: HTTPMethod;
|
|
407
|
-
path: string;
|
|
408
|
-
headers: Record<string, string>;
|
|
409
|
-
query?: Record<string, string | string[]>;
|
|
410
|
-
body?: unknown;
|
|
411
|
-
signal?: AbortSignal;
|
|
412
|
-
}): Promise<SockressClientResponse<T>> {
|
|
413
|
-
const url = buildHttpUrl(this.options.baseUrl, input.path, input.query);
|
|
414
|
-
const headers = new Headers(input.headers);
|
|
415
|
-
const init: RequestInit = {
|
|
416
|
-
method: input.method,
|
|
417
|
-
headers,
|
|
418
|
-
credentials: this.options.credentials,
|
|
419
|
-
signal: input.signal
|
|
420
|
-
};
|
|
421
|
-
if (input.body !== undefined && input.method !== 'GET' && input.method !== 'HEAD') {
|
|
422
|
-
if (
|
|
423
|
-
typeof input.body === 'string' ||
|
|
424
|
-
input.body instanceof URLSearchParams ||
|
|
425
|
-
isBlob(input.body) ||
|
|
426
|
-
isFormData(input.body)
|
|
427
|
-
) {
|
|
428
|
-
init.body = input.body as BodyInit;
|
|
429
|
-
} else {
|
|
430
|
-
init.body = JSON.stringify(input.body);
|
|
431
|
-
if (!headers.has('content-type')) {
|
|
432
|
-
headers.set('content-type', 'application/json');
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
const response = await this.options.fetchImpl(url, init);
|
|
437
|
-
const text = await response.text();
|
|
438
|
-
let parsed: unknown = text;
|
|
439
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
440
|
-
if (contentType.includes('application/json') && text) {
|
|
441
|
-
try {
|
|
442
|
-
parsed = JSON.parse(text);
|
|
443
|
-
} catch {
|
|
444
|
-
parsed = text;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
const headersObj: Record<string, string> = {};
|
|
448
|
-
response.headers.forEach((value, key) => {
|
|
449
|
-
headersObj[key.toLowerCase()] = value;
|
|
450
|
-
});
|
|
451
|
-
return {
|
|
452
|
-
status: response.status,
|
|
453
|
-
ok: response.ok,
|
|
454
|
-
headers: headersObj,
|
|
455
|
-
body: parsed as T,
|
|
456
|
-
json: <R>() => parsed as R,
|
|
457
|
-
text: () => text,
|
|
458
|
-
raw: () => parsed as T
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
close(): void {
|
|
463
|
-
if (this.closeRequested) return;
|
|
464
|
-
this.closeRequested = true;
|
|
465
|
-
this.socketEnabled = false;
|
|
466
|
-
if (this.ws) {
|
|
467
|
-
this.ws.close();
|
|
468
|
-
this.ws = undefined;
|
|
469
|
-
}
|
|
470
|
-
this.queue = [];
|
|
471
|
-
this.rejectAllPending(new Error('Client closed'));
|
|
472
|
-
this.lifecycleTeardown.forEach((teardown) => teardown());
|
|
473
|
-
this.lifecycleTeardown = [];
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export function sockressClient(options: SockressClientOptions): SockressClient {
|
|
478
|
-
return SockressClient.create(options);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export const createSockressClient = sockressClient;
|
|
482
|
-
|
|
483
|
-
function normalizeOptions(options: SockressClientOptions): NormalizedOptions {
|
|
484
|
-
if (!options.baseUrl) {
|
|
485
|
-
throw new Error('baseUrl is required');
|
|
486
|
-
}
|
|
487
|
-
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
488
|
-
if (!fetchImpl) {
|
|
489
|
-
throw new Error('Fetch implementation is not available in this environment');
|
|
490
|
-
}
|
|
491
|
-
const wsFactory =
|
|
492
|
-
options.wsFactory ??
|
|
493
|
-
(typeof WebSocket !== 'undefined' ? (url: string) => new WebSocket(url) as WebSocketLike : undefined);
|
|
494
|
-
return {
|
|
495
|
-
baseUrl: options.baseUrl.replace(/\/+$/, ''),
|
|
496
|
-
socketPath: options.socketPath ?? '/sockress',
|
|
497
|
-
headers: { ...(options.headers ?? {}) },
|
|
498
|
-
timeout: options.timeout ?? 15_000,
|
|
499
|
-
reconnectInterval: options.reconnectInterval ?? 1_000,
|
|
500
|
-
maxReconnectInterval: options.maxReconnectInterval ?? 15_000,
|
|
501
|
-
autoConnect: options.autoConnect ?? true,
|
|
502
|
-
preferSocket: options.preferSocket ?? true,
|
|
503
|
-
fetchImpl,
|
|
504
|
-
wsFactory,
|
|
505
|
-
credentials: options.credentials ?? 'include'
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
async function serializeBodyForSocket(body: unknown): Promise<unknown> {
|
|
510
|
-
if (isFormData(body)) {
|
|
511
|
-
return {
|
|
512
|
-
__formData: await serializeFormData(body)
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
return body;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function serializeFormData(formData: FormData): Promise<{
|
|
519
|
-
fields: Record<string, string | string[]>;
|
|
520
|
-
files: Record<string, SerializedSocketFilePayload[]>;
|
|
521
|
-
}> {
|
|
522
|
-
const fields: Record<string, string | string[]> = {};
|
|
523
|
-
const files: Record<string, SerializedSocketFilePayload[]> = {};
|
|
524
|
-
const entries: Array<[string, any]> = collectFormDataEntries(formData);
|
|
525
|
-
for (const [key, value] of entries) {
|
|
526
|
-
if (typeof value === 'string') {
|
|
527
|
-
if (fields[key]) {
|
|
528
|
-
const existing = fields[key];
|
|
529
|
-
fields[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
|
530
|
-
} else {
|
|
531
|
-
fields[key] = value;
|
|
532
|
-
}
|
|
533
|
-
continue;
|
|
534
|
-
}
|
|
535
|
-
const fileBuffer = await value.arrayBuffer();
|
|
536
|
-
const encoded = arrayBufferToBase64(fileBuffer);
|
|
537
|
-
if (!files[key]) {
|
|
538
|
-
files[key] = [];
|
|
539
|
-
}
|
|
540
|
-
files[key].push({
|
|
541
|
-
fieldName: key,
|
|
542
|
-
name: value.name ?? 'file',
|
|
543
|
-
type: value.type ?? 'application/octet-stream',
|
|
544
|
-
size: value.size,
|
|
545
|
-
lastModified: typeof value.lastModified === 'number' ? value.lastModified : undefined,
|
|
546
|
-
data: encoded
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
return { fields, files };
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
interface SerializedSocketFilePayload {
|
|
553
|
-
fieldName: string;
|
|
554
|
-
name: string;
|
|
555
|
-
type: string;
|
|
556
|
-
size: number;
|
|
557
|
-
data: string;
|
|
558
|
-
lastModified?: number;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function buildSocketUrl(baseUrl: string, socketPath: string): string {
|
|
562
|
-
const url = new URL(socketPath, baseUrl);
|
|
563
|
-
url.protocol = url.protocol.replace('http', 'ws');
|
|
564
|
-
return url.toString();
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function buildHttpUrl(
|
|
568
|
-
baseUrl: string,
|
|
569
|
-
path: string,
|
|
570
|
-
query?: Record<string, string | string[]>
|
|
571
|
-
): string {
|
|
572
|
-
const url = new URL(path, baseUrl);
|
|
573
|
-
if (query) {
|
|
574
|
-
for (const [key, value] of Object.entries(query)) {
|
|
575
|
-
if (Array.isArray(value)) {
|
|
576
|
-
value.forEach((entry) => url.searchParams.append(key, entry));
|
|
577
|
-
} else {
|
|
578
|
-
url.searchParams.append(key, value);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
return url.toString();
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function normalizePath(path: string): string {
|
|
586
|
-
if (!path.startsWith('/')) {
|
|
587
|
-
return `/${path}`;
|
|
588
|
-
}
|
|
589
|
-
return path;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function normalizeQuery(
|
|
593
|
-
query: Record<string, string | number | boolean | Array<string | number | boolean>>
|
|
594
|
-
): Record<string, string | string[]> {
|
|
595
|
-
const result: Record<string, string | string[]> = {};
|
|
596
|
-
for (const [key, value] of Object.entries(query)) {
|
|
597
|
-
if (Array.isArray(value)) {
|
|
598
|
-
result[key] = value.map((item) => String(item));
|
|
599
|
-
} else {
|
|
600
|
-
result[key] = String(value);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return result;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function createClientResponse<T = unknown>(payload: SocketResponsePayload): SockressClientResponse<T> {
|
|
607
|
-
const headers = payload.headers ?? {};
|
|
608
|
-
const status = payload.status;
|
|
609
|
-
const body = payload.body as T;
|
|
610
|
-
return {
|
|
611
|
-
status,
|
|
612
|
-
ok: status >= 200 && status < 300,
|
|
613
|
-
headers,
|
|
614
|
-
body,
|
|
615
|
-
json: <R = T>() => body as unknown as R,
|
|
616
|
-
text: () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
617
|
-
raw: () => body
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function extractCloseDetails(event: unknown): { code?: number; reason?: string } {
|
|
622
|
-
if (!event || typeof event !== 'object') return {};
|
|
623
|
-
const closeEvent = event as Partial<CloseEvent>;
|
|
624
|
-
return {
|
|
625
|
-
code: closeEvent.code,
|
|
626
|
-
reason: closeEvent.reason
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function resolveEventData(event: unknown): string {
|
|
631
|
-
if (!event) return '';
|
|
632
|
-
if (typeof event === 'string') return event;
|
|
633
|
-
if (typeof event === 'object' && 'data' in (event as Record<string, unknown>)) {
|
|
634
|
-
return toText((event as { data: unknown }).data);
|
|
635
|
-
}
|
|
636
|
-
return toText(event);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const textDecoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
|
640
|
-
|
|
641
|
-
function toText(value: unknown): string {
|
|
642
|
-
if (typeof value === 'string') {
|
|
643
|
-
return value;
|
|
644
|
-
}
|
|
645
|
-
if (value instanceof ArrayBuffer) {
|
|
646
|
-
return textDecoder ? textDecoder.decode(value) : '';
|
|
647
|
-
}
|
|
648
|
-
if (ArrayBuffer.isView(value)) {
|
|
649
|
-
const view = value as ArrayBufferView;
|
|
650
|
-
if (textDecoder) {
|
|
651
|
-
return textDecoder.decode(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
if (value && typeof (value as { toString: () => string }).toString === 'function') {
|
|
655
|
-
return (value as { toString: () => string }).toString();
|
|
656
|
-
}
|
|
657
|
-
return '';
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function isBlob(value: unknown): value is Blob {
|
|
661
|
-
return typeof Blob !== 'undefined' && value instanceof Blob;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function isFormData(value: unknown): value is FormData {
|
|
665
|
-
return typeof FormData !== 'undefined' && value instanceof FormData;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
669
|
-
if (typeof Buffer !== 'undefined') {
|
|
670
|
-
return Buffer.from(buffer).toString('base64');
|
|
671
|
-
}
|
|
672
|
-
let binary = '';
|
|
673
|
-
const bytes = new Uint8Array(buffer);
|
|
674
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
675
|
-
binary += String.fromCharCode(bytes[i]);
|
|
676
|
-
}
|
|
677
|
-
if (typeof btoa === 'function') {
|
|
678
|
-
return btoa(binary);
|
|
679
|
-
}
|
|
680
|
-
throw new Error('Base64 encoding is not supported in this environment');
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function collectFormDataEntries(formData: FormData): Array<[string, any]> {
|
|
684
|
-
const anyForm = formData as any;
|
|
685
|
-
const entries: Array<[string, any]> = [];
|
|
686
|
-
if (typeof anyForm.entries === 'function') {
|
|
687
|
-
for (const pair of anyForm.entries()) {
|
|
688
|
-
entries.push(pair);
|
|
689
|
-
}
|
|
690
|
-
return entries;
|
|
691
|
-
}
|
|
692
|
-
if (typeof anyForm[Symbol.iterator] === 'function') {
|
|
693
|
-
for (const pair of anyForm as Iterable<[string, any]>) {
|
|
694
|
-
entries.push(pair);
|
|
695
|
-
}
|
|
696
|
-
return entries;
|
|
697
|
-
}
|
|
698
|
-
throw new Error('FormData implementation does not support iteration');
|
|
699
|
-
}
|
|
700
|
-
|