@ecodrix/erix-client 1.0.5 → 1.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/dist/index.d.ts +33 -0
- package/dist/index.js +102 -19
- package/dist/transports/HttpTransport.d.ts +49 -0
- package/dist/transports/HttpTransport.js +71 -0
- package/dist/transports/TransportLayer.d.ts +42 -0
- package/dist/transports/TransportLayer.js +9 -0
- package/dist/transports/WebSocketTransport.d.ts +86 -0
- package/dist/transports/WebSocketTransport.js +299 -0
- package/package.json +6 -2
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* const session = await store.get<{ role: string }>('session:user1')
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
|
+
import type { TransportLayer, TransportMode } from "./transports/TransportLayer.js";
|
|
23
|
+
export { HttpTransport } from "./transports/HttpTransport.js";
|
|
24
|
+
export { WebSocketTransport } from "./transports/WebSocketTransport.js";
|
|
25
|
+
export type { TransportLayer, TransportMode };
|
|
22
26
|
/** Any value that can be serialized to and from JSON */
|
|
23
27
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
24
28
|
[key: string]: JsonValue;
|
|
@@ -32,6 +36,13 @@ export interface ErixClientOptions {
|
|
|
32
36
|
tenantId: string;
|
|
33
37
|
/** Optional request timeout in milliseconds (default: 5000) */
|
|
34
38
|
timeoutMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Transport mode:
|
|
41
|
+
* - "http": Use HTTP only (original behavior)
|
|
42
|
+
* - "ws": Use WebSocket only
|
|
43
|
+
* - "auto": Attempt WebSocket first, fall back to HTTP if unavailable (default)
|
|
44
|
+
*/
|
|
45
|
+
transport?: "http" | "ws" | "auto";
|
|
35
46
|
}
|
|
36
47
|
export interface RateLimitResult {
|
|
37
48
|
allowed: boolean;
|
|
@@ -91,8 +102,30 @@ export declare class ErixClient {
|
|
|
91
102
|
private readonly baseUrl;
|
|
92
103
|
private readonly headers;
|
|
93
104
|
private readonly timeoutMs;
|
|
105
|
+
private readonly transportMode;
|
|
106
|
+
private transport;
|
|
94
107
|
constructor(options: ErixClientOptions);
|
|
108
|
+
/**
|
|
109
|
+
* Create the appropriate transport based on the configured mode.
|
|
110
|
+
* In "auto" mode, starts with WebSocket and falls back to HTTP on failure.
|
|
111
|
+
*/
|
|
112
|
+
private createTransport;
|
|
95
113
|
private req;
|
|
114
|
+
/**
|
|
115
|
+
* Execute multiple operations in a single round-trip (pipeline).
|
|
116
|
+
* Over WebSocket, this sends all requests in one frame.
|
|
117
|
+
* Over HTTP, requests are executed concurrently via Promise.all.
|
|
118
|
+
*/
|
|
119
|
+
pipeline(requests: Array<{
|
|
120
|
+
method: string;
|
|
121
|
+
path: string;
|
|
122
|
+
body?: unknown;
|
|
123
|
+
params?: Record<string, string>;
|
|
124
|
+
}>): Promise<unknown[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Close the transport and release resources.
|
|
127
|
+
*/
|
|
128
|
+
close(): void;
|
|
96
129
|
/** Check if the erix-store instance is reachable */
|
|
97
130
|
ping(): Promise<{
|
|
98
131
|
status: string;
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* const session = await store.get<{ role: string }>('session:user1')
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
|
+
import { HttpTransport } from "./transports/HttpTransport.js";
|
|
23
|
+
import { WebSocketTransport } from "./transports/WebSocketTransport.js";
|
|
24
|
+
export { HttpTransport } from "./transports/HttpTransport.js";
|
|
25
|
+
export { WebSocketTransport } from "./transports/WebSocketTransport.js";
|
|
22
26
|
// ─── Client ─────────────────────────────────────────────────────────────────
|
|
23
27
|
export class ErixClient {
|
|
24
28
|
constructor(options) {
|
|
@@ -323,39 +327,75 @@ export class ErixClient {
|
|
|
323
327
|
};
|
|
324
328
|
this.baseUrl = options.baseUrl.replace(/\/$/, ""); // strip trailing slash
|
|
325
329
|
this.timeoutMs = options.timeoutMs ?? 5000;
|
|
330
|
+
this.transportMode = options.transport ?? "auto";
|
|
326
331
|
this.headers = {
|
|
327
332
|
"x-erix-key": options.apiKey,
|
|
328
333
|
"x-tenant-id": options.tenantId,
|
|
329
334
|
"Content-Type": "application/json",
|
|
330
335
|
};
|
|
336
|
+
// Initialize transport based on mode
|
|
337
|
+
this.transport = this.createTransport();
|
|
331
338
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Create the appropriate transport based on the configured mode.
|
|
341
|
+
* In "auto" mode, starts with WebSocket and falls back to HTTP on failure.
|
|
342
|
+
*/
|
|
343
|
+
createTransport() {
|
|
344
|
+
const httpTransport = new HttpTransport({
|
|
345
|
+
baseUrl: this.baseUrl,
|
|
346
|
+
headers: this.headers,
|
|
347
|
+
timeoutMs: this.timeoutMs,
|
|
348
|
+
});
|
|
349
|
+
if (this.transportMode === "http") {
|
|
350
|
+
return httpTransport;
|
|
339
351
|
}
|
|
340
|
-
|
|
341
|
-
const
|
|
352
|
+
// For "ws" and "auto" modes, create a WebSocket transport
|
|
353
|
+
const wsUrl = this.baseUrl
|
|
354
|
+
.replace(/^https:/, "wss:")
|
|
355
|
+
.replace(/^http:/, "ws:");
|
|
342
356
|
try {
|
|
343
|
-
const
|
|
344
|
-
|
|
357
|
+
const wsTransport = new WebSocketTransport({
|
|
358
|
+
url: wsUrl,
|
|
345
359
|
headers: this.headers,
|
|
346
|
-
|
|
347
|
-
|
|
360
|
+
timeoutMs: this.timeoutMs,
|
|
361
|
+
reconnect: {
|
|
362
|
+
initialDelayMs: 1000,
|
|
363
|
+
maxDelayMs: 30000,
|
|
364
|
+
backoffFactor: 2,
|
|
365
|
+
},
|
|
348
366
|
});
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
throw new Error(`[erix-store] ${method} ${path} → ${res.status}: ${err.error ?? res.statusText}`);
|
|
367
|
+
if (this.transportMode === "ws") {
|
|
368
|
+
return wsTransport;
|
|
352
369
|
}
|
|
353
|
-
|
|
370
|
+
// "auto" mode: use WebSocket with HTTP as fallback
|
|
371
|
+
return new AutoTransport(wsTransport, httpTransport);
|
|
354
372
|
}
|
|
355
|
-
|
|
356
|
-
|
|
373
|
+
catch {
|
|
374
|
+
// If WebSocket creation fails in "auto" mode, fall back to HTTP
|
|
375
|
+
if (this.transportMode === "auto") {
|
|
376
|
+
return httpTransport;
|
|
377
|
+
}
|
|
378
|
+
throw new Error("[erix-store] Failed to create WebSocket transport in 'ws' mode");
|
|
357
379
|
}
|
|
358
380
|
}
|
|
381
|
+
// ── Internal fetch helper ─────────────────────────────────────────────
|
|
382
|
+
async req(method, path, body, params) {
|
|
383
|
+
return this.transport.request(method, path, body, params);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Execute multiple operations in a single round-trip (pipeline).
|
|
387
|
+
* Over WebSocket, this sends all requests in one frame.
|
|
388
|
+
* Over HTTP, requests are executed concurrently via Promise.all.
|
|
389
|
+
*/
|
|
390
|
+
async pipeline(requests) {
|
|
391
|
+
return this.transport.pipeline(requests);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Close the transport and release resources.
|
|
395
|
+
*/
|
|
396
|
+
close() {
|
|
397
|
+
this.transport.close();
|
|
398
|
+
}
|
|
359
399
|
// ─── Health ───────────────────────────────────────────────────────────
|
|
360
400
|
/** Check if the erix-store instance is reachable */
|
|
361
401
|
async ping() {
|
|
@@ -462,3 +502,46 @@ export class ErixClient {
|
|
|
462
502
|
return { close: () => controller.abort() };
|
|
463
503
|
}
|
|
464
504
|
}
|
|
505
|
+
// ─── AutoTransport (internal) ────────────────────────────────────────────
|
|
506
|
+
/**
|
|
507
|
+
* Internal transport that tries WebSocket first and falls back to HTTP
|
|
508
|
+
* if the WebSocket is not connected. Used in "auto" mode.
|
|
509
|
+
*/
|
|
510
|
+
class AutoTransport {
|
|
511
|
+
constructor(ws, http) {
|
|
512
|
+
this.ws = ws;
|
|
513
|
+
this.http = http;
|
|
514
|
+
}
|
|
515
|
+
get isConnected() {
|
|
516
|
+
return this.ws.isConnected || this.http.isConnected;
|
|
517
|
+
}
|
|
518
|
+
async request(method, path, body, params) {
|
|
519
|
+
// If WebSocket is connected, use it
|
|
520
|
+
if (this.ws.isConnected) {
|
|
521
|
+
try {
|
|
522
|
+
return await this.ws.request(method, path, body, params);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// Fall back to HTTP on WebSocket failure
|
|
526
|
+
return this.http.request(method, path, body, params);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Otherwise use HTTP
|
|
530
|
+
return this.http.request(method, path, body, params);
|
|
531
|
+
}
|
|
532
|
+
async pipeline(requests) {
|
|
533
|
+
if (this.ws.isConnected) {
|
|
534
|
+
try {
|
|
535
|
+
return await this.ws.pipeline(requests);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return this.http.pipeline(requests);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return this.http.pipeline(requests);
|
|
542
|
+
}
|
|
543
|
+
close() {
|
|
544
|
+
this.ws.close();
|
|
545
|
+
this.http.close();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file HttpTransport.ts
|
|
3
|
+
* @module ErixClient/Transports/HttpTransport
|
|
4
|
+
*
|
|
5
|
+
* HTTP transport for @ecodrix/erix-client.
|
|
6
|
+
*
|
|
7
|
+
* Wraps the existing fetch-based request logic into a TransportLayer
|
|
8
|
+
* implementation. This is the default/fallback transport that preserves
|
|
9
|
+
* the original behavior of the client.
|
|
10
|
+
*/
|
|
11
|
+
import type { TransportLayer } from "./TransportLayer.js";
|
|
12
|
+
export interface HttpTransportOptions {
|
|
13
|
+
/** Full base URL of the erix-store instance */
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
/** Headers to include on every request (auth, tenant, content-type) */
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
/** Request timeout in milliseconds (default: 5000) */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare class HttpTransport implements TransportLayer {
|
|
21
|
+
private readonly baseUrl;
|
|
22
|
+
private readonly headers;
|
|
23
|
+
private readonly timeoutMs;
|
|
24
|
+
private closed;
|
|
25
|
+
constructor(options: HttpTransportOptions);
|
|
26
|
+
/**
|
|
27
|
+
* HTTP is always "connected" — each request is independent.
|
|
28
|
+
*/
|
|
29
|
+
get isConnected(): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Send a single HTTP request using native fetch.
|
|
32
|
+
*/
|
|
33
|
+
request(method: string, path: string, body?: unknown, params?: Record<string, string>): Promise<unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* Pipeline over HTTP — executes requests sequentially since HTTP
|
|
36
|
+
* doesn't natively support multiplexing in a single connection.
|
|
37
|
+
* Still provides the same API contract as WebSocket pipeline.
|
|
38
|
+
*/
|
|
39
|
+
pipeline(requests: Array<{
|
|
40
|
+
method: string;
|
|
41
|
+
path: string;
|
|
42
|
+
body?: unknown;
|
|
43
|
+
params?: Record<string, string>;
|
|
44
|
+
}>): Promise<unknown[]>;
|
|
45
|
+
/**
|
|
46
|
+
* Mark the transport as closed. Subsequent requests will throw.
|
|
47
|
+
*/
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file HttpTransport.ts
|
|
3
|
+
* @module ErixClient/Transports/HttpTransport
|
|
4
|
+
*
|
|
5
|
+
* HTTP transport for @ecodrix/erix-client.
|
|
6
|
+
*
|
|
7
|
+
* Wraps the existing fetch-based request logic into a TransportLayer
|
|
8
|
+
* implementation. This is the default/fallback transport that preserves
|
|
9
|
+
* the original behavior of the client.
|
|
10
|
+
*/
|
|
11
|
+
export class HttpTransport {
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.closed = false;
|
|
14
|
+
this.baseUrl = options.baseUrl;
|
|
15
|
+
this.headers = options.headers;
|
|
16
|
+
this.timeoutMs = options.timeoutMs ?? 5000;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* HTTP is always "connected" — each request is independent.
|
|
20
|
+
*/
|
|
21
|
+
get isConnected() {
|
|
22
|
+
return !this.closed;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Send a single HTTP request using native fetch.
|
|
26
|
+
*/
|
|
27
|
+
async request(method, path, body, params) {
|
|
28
|
+
if (this.closed) {
|
|
29
|
+
throw new Error("[erix-store] HttpTransport has been closed");
|
|
30
|
+
}
|
|
31
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
32
|
+
if (params) {
|
|
33
|
+
for (const [k, v] of Object.entries(params)) {
|
|
34
|
+
url.searchParams.set(k, v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(url.toString(), {
|
|
41
|
+
method,
|
|
42
|
+
headers: this.headers,
|
|
43
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const err = (await res.json().catch(() => ({})));
|
|
48
|
+
throw new Error(`[erix-store] ${method} ${path} → ${res.status}: ${err.error ?? res.statusText}`);
|
|
49
|
+
}
|
|
50
|
+
return await res.json();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Pipeline over HTTP — executes requests sequentially since HTTP
|
|
58
|
+
* doesn't natively support multiplexing in a single connection.
|
|
59
|
+
* Still provides the same API contract as WebSocket pipeline.
|
|
60
|
+
*/
|
|
61
|
+
async pipeline(requests) {
|
|
62
|
+
// Execute all requests concurrently via Promise.all
|
|
63
|
+
return Promise.all(requests.map((r) => this.request(r.method, r.path, r.body, r.params)));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Mark the transport as closed. Subsequent requests will throw.
|
|
67
|
+
*/
|
|
68
|
+
close() {
|
|
69
|
+
this.closed = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file TransportLayer.ts
|
|
3
|
+
* @module ErixClient/Transports/TransportLayer
|
|
4
|
+
*
|
|
5
|
+
* Strategy interface for erix-client transports.
|
|
6
|
+
* Both HttpTransport and WebSocketTransport implement this interface,
|
|
7
|
+
* allowing the ErixClient to swap transports transparently.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Common interface that all transport implementations must satisfy.
|
|
11
|
+
* Provides request/response semantics regardless of underlying protocol.
|
|
12
|
+
*/
|
|
13
|
+
export interface TransportLayer {
|
|
14
|
+
/**
|
|
15
|
+
* Send a request and await the response.
|
|
16
|
+
* @param method HTTP-like method (GET, POST, DELETE, PATCH)
|
|
17
|
+
* @param path Route path (e.g. /core/get, /queue/v2/jobs)
|
|
18
|
+
* @param body Optional request body
|
|
19
|
+
* @param params Optional query parameters
|
|
20
|
+
*/
|
|
21
|
+
request(method: string, path: string, body?: unknown, params?: Record<string, string>): Promise<unknown>;
|
|
22
|
+
/**
|
|
23
|
+
* Send a batch of requests as a single round-trip (pipeline).
|
|
24
|
+
* Returns an array of results in the same order as the input requests.
|
|
25
|
+
*/
|
|
26
|
+
pipeline(requests: Array<{
|
|
27
|
+
method: string;
|
|
28
|
+
path: string;
|
|
29
|
+
body?: unknown;
|
|
30
|
+
params?: Record<string, string>;
|
|
31
|
+
}>): Promise<unknown[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Close the transport and release resources.
|
|
34
|
+
*/
|
|
35
|
+
close(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Whether the transport is currently connected/ready.
|
|
38
|
+
*/
|
|
39
|
+
readonly isConnected: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** Transport mode option for ErixClient */
|
|
42
|
+
export type TransportMode = "http" | "ws" | "auto";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file TransportLayer.ts
|
|
3
|
+
* @module ErixClient/Transports/TransportLayer
|
|
4
|
+
*
|
|
5
|
+
* Strategy interface for erix-client transports.
|
|
6
|
+
* Both HttpTransport and WebSocketTransport implement this interface,
|
|
7
|
+
* allowing the ErixClient to swap transports transparently.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WebSocketTransport.ts
|
|
3
|
+
* @module ErixClient/Transports/WebSocket
|
|
4
|
+
*
|
|
5
|
+
* WebSocket transport for @ecodrix/erix-client.
|
|
6
|
+
*
|
|
7
|
+
* Provides persistent binary (MessagePack) communication with erix-store,
|
|
8
|
+
* supporting request/response multiplexing via correlation IDs, pipeline
|
|
9
|
+
* batching, automatic reconnection with exponential backoff, and request
|
|
10
|
+
* buffering during disconnection.
|
|
11
|
+
*/
|
|
12
|
+
import type { TransportLayer } from "./TransportLayer.js";
|
|
13
|
+
export interface TransportOptions {
|
|
14
|
+
/** WebSocket URL, e.g. ws://localhost:9876 */
|
|
15
|
+
url: string;
|
|
16
|
+
/** Headers to send on connection (auth, tenant) */
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
/** Request timeout in milliseconds (default: 5000) */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Reconnection options */
|
|
21
|
+
reconnect?: {
|
|
22
|
+
/** Initial delay before first reconnect attempt (default: 1000) */
|
|
23
|
+
initialDelayMs?: number;
|
|
24
|
+
/** Maximum delay between reconnect attempts (default: 30000) */
|
|
25
|
+
maxDelayMs?: number;
|
|
26
|
+
/** Backoff multiplier (default: 2) */
|
|
27
|
+
backoffFactor?: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export declare class TimeoutError extends Error {
|
|
31
|
+
constructor(id: number, timeoutMs: number);
|
|
32
|
+
}
|
|
33
|
+
export declare class ConnectionLostError extends Error {
|
|
34
|
+
constructor();
|
|
35
|
+
}
|
|
36
|
+
export declare class TransportClosedError extends Error {
|
|
37
|
+
constructor();
|
|
38
|
+
}
|
|
39
|
+
export declare class WebSocketTransport implements TransportLayer {
|
|
40
|
+
private ws;
|
|
41
|
+
private pending;
|
|
42
|
+
private buffer;
|
|
43
|
+
private nextId;
|
|
44
|
+
private connected;
|
|
45
|
+
private closed;
|
|
46
|
+
private reconnectAttempt;
|
|
47
|
+
private reconnectTimer;
|
|
48
|
+
private readonly url;
|
|
49
|
+
private readonly headers;
|
|
50
|
+
private readonly timeoutMs;
|
|
51
|
+
private readonly initialDelayMs;
|
|
52
|
+
private readonly maxDelayMs;
|
|
53
|
+
private readonly backoffFactor;
|
|
54
|
+
constructor(options: TransportOptions);
|
|
55
|
+
/**
|
|
56
|
+
* Send a request and await the correlated response.
|
|
57
|
+
* If disconnected, the request is buffered and replayed on reconnect.
|
|
58
|
+
*/
|
|
59
|
+
request(method: string, path: string, body?: unknown, params?: Record<string, string>): Promise<unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* Send a pipeline batch and await all responses.
|
|
62
|
+
* All operations are sent in a single frame and resolved together.
|
|
63
|
+
*/
|
|
64
|
+
pipeline(requests: Array<{
|
|
65
|
+
method: string;
|
|
66
|
+
path: string;
|
|
67
|
+
body?: unknown;
|
|
68
|
+
params?: Record<string, string>;
|
|
69
|
+
}>): Promise<unknown[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Close the transport. Rejects all pending requests, clears timers,
|
|
72
|
+
* and closes the WebSocket connection.
|
|
73
|
+
*/
|
|
74
|
+
close(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Returns whether the transport is currently connected.
|
|
77
|
+
*/
|
|
78
|
+
get isConnected(): boolean;
|
|
79
|
+
private connect;
|
|
80
|
+
private handleDisconnect;
|
|
81
|
+
private scheduleReconnect;
|
|
82
|
+
private replayBuffer;
|
|
83
|
+
private handleMessage;
|
|
84
|
+
private registerPending;
|
|
85
|
+
private registerPipelinePending;
|
|
86
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WebSocketTransport.ts
|
|
3
|
+
* @module ErixClient/Transports/WebSocket
|
|
4
|
+
*
|
|
5
|
+
* WebSocket transport for @ecodrix/erix-client.
|
|
6
|
+
*
|
|
7
|
+
* Provides persistent binary (MessagePack) communication with erix-store,
|
|
8
|
+
* supporting request/response multiplexing via correlation IDs, pipeline
|
|
9
|
+
* batching, automatic reconnection with exponential backoff, and request
|
|
10
|
+
* buffering during disconnection.
|
|
11
|
+
*/
|
|
12
|
+
import { pack, unpack } from "msgpackr";
|
|
13
|
+
import WebSocket from "ws";
|
|
14
|
+
// ─── Errors ─────────────────────────────────────────────────────────────────
|
|
15
|
+
export class TimeoutError extends Error {
|
|
16
|
+
constructor(id, timeoutMs) {
|
|
17
|
+
super(`Request ${id} timed out after ${timeoutMs}ms`);
|
|
18
|
+
this.name = "TimeoutError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class ConnectionLostError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("WebSocket connection lost");
|
|
24
|
+
this.name = "ConnectionLostError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class TransportClosedError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("WebSocket transport has been closed");
|
|
30
|
+
this.name = "TransportClosedError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ─── WebSocketTransport ─────────────────────────────────────────────────────
|
|
34
|
+
export class WebSocketTransport {
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.ws = null;
|
|
37
|
+
this.pending = new Map();
|
|
38
|
+
this.buffer = [];
|
|
39
|
+
this.nextId = 1;
|
|
40
|
+
this.connected = false;
|
|
41
|
+
this.closed = false;
|
|
42
|
+
this.reconnectAttempt = 0;
|
|
43
|
+
this.reconnectTimer = null;
|
|
44
|
+
this.url = options.url;
|
|
45
|
+
this.headers = options.headers;
|
|
46
|
+
this.timeoutMs = options.timeoutMs ?? 5000;
|
|
47
|
+
this.initialDelayMs = options.reconnect?.initialDelayMs ?? 1000;
|
|
48
|
+
this.maxDelayMs = options.reconnect?.maxDelayMs ?? 30000;
|
|
49
|
+
this.backoffFactor = options.reconnect?.backoffFactor ?? 2;
|
|
50
|
+
this.connect();
|
|
51
|
+
}
|
|
52
|
+
// ─── Public API ─────────────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* Send a request and await the correlated response.
|
|
55
|
+
* If disconnected, the request is buffered and replayed on reconnect.
|
|
56
|
+
*/
|
|
57
|
+
request(method, path, body, params) {
|
|
58
|
+
if (this.closed) {
|
|
59
|
+
return Promise.reject(new TransportClosedError());
|
|
60
|
+
}
|
|
61
|
+
const id = this.nextId++;
|
|
62
|
+
const frame = { id, method, path };
|
|
63
|
+
if (body !== undefined)
|
|
64
|
+
frame.body = body;
|
|
65
|
+
if (params !== undefined)
|
|
66
|
+
frame.params = params;
|
|
67
|
+
const encoded = pack(frame);
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
70
|
+
this.registerPending(id, resolve, reject);
|
|
71
|
+
this.ws.send(encoded);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Buffer for replay on reconnect
|
|
75
|
+
this.buffer.push({ frame: encoded, resolve, reject });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Send a pipeline batch and await all responses.
|
|
81
|
+
* All operations are sent in a single frame and resolved together.
|
|
82
|
+
*/
|
|
83
|
+
pipeline(requests) {
|
|
84
|
+
if (this.closed) {
|
|
85
|
+
return Promise.reject(new TransportClosedError());
|
|
86
|
+
}
|
|
87
|
+
const pipelineId = this.nextId++;
|
|
88
|
+
const subRequests = requests.map((r) => {
|
|
89
|
+
const subId = this.nextId++;
|
|
90
|
+
const frame = { id: subId, method: r.method, path: r.path };
|
|
91
|
+
if (r.body !== undefined)
|
|
92
|
+
frame.body = r.body;
|
|
93
|
+
if (r.params !== undefined)
|
|
94
|
+
frame.params = r.params;
|
|
95
|
+
return frame;
|
|
96
|
+
});
|
|
97
|
+
const pipelineFrame = {
|
|
98
|
+
id: pipelineId,
|
|
99
|
+
pipeline: true,
|
|
100
|
+
requests: subRequests,
|
|
101
|
+
};
|
|
102
|
+
const encoded = pack(pipelineFrame);
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
105
|
+
this.registerPipelinePending(pipelineId, resolve, reject);
|
|
106
|
+
this.ws.send(encoded);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Buffer pipeline request — wrap resolve/reject to handle pipeline response
|
|
110
|
+
const wrappedResolve = (data) => {
|
|
111
|
+
const pipelineResp = data;
|
|
112
|
+
resolve(pipelineResp.responses.map((r) => r.data));
|
|
113
|
+
};
|
|
114
|
+
this.buffer.push({ frame: encoded, resolve: wrappedResolve, reject });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Close the transport. Rejects all pending requests, clears timers,
|
|
120
|
+
* and closes the WebSocket connection.
|
|
121
|
+
*/
|
|
122
|
+
close() {
|
|
123
|
+
if (this.closed)
|
|
124
|
+
return;
|
|
125
|
+
this.closed = true;
|
|
126
|
+
// Clear reconnect timer
|
|
127
|
+
if (this.reconnectTimer !== null) {
|
|
128
|
+
clearTimeout(this.reconnectTimer);
|
|
129
|
+
this.reconnectTimer = null;
|
|
130
|
+
}
|
|
131
|
+
// Reject all pending requests
|
|
132
|
+
const closedError = new TransportClosedError();
|
|
133
|
+
for (const [_id, pending] of this.pending) {
|
|
134
|
+
clearTimeout(pending.timeout);
|
|
135
|
+
pending.reject(closedError);
|
|
136
|
+
}
|
|
137
|
+
this.pending.clear();
|
|
138
|
+
// Reject all buffered requests
|
|
139
|
+
for (const buffered of this.buffer) {
|
|
140
|
+
buffered.reject(closedError);
|
|
141
|
+
}
|
|
142
|
+
this.buffer = [];
|
|
143
|
+
// Close WebSocket
|
|
144
|
+
if (this.ws) {
|
|
145
|
+
this.ws.removeAllListeners();
|
|
146
|
+
if (this.ws.readyState === WebSocket.OPEN ||
|
|
147
|
+
this.ws.readyState === WebSocket.CONNECTING) {
|
|
148
|
+
this.ws.close(1000, "Client closing");
|
|
149
|
+
}
|
|
150
|
+
this.ws = null;
|
|
151
|
+
}
|
|
152
|
+
this.connected = false;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Returns whether the transport is currently connected.
|
|
156
|
+
*/
|
|
157
|
+
get isConnected() {
|
|
158
|
+
return this.connected;
|
|
159
|
+
}
|
|
160
|
+
// ─── Connection Management ──────────────────────────────────────────────
|
|
161
|
+
connect() {
|
|
162
|
+
if (this.closed)
|
|
163
|
+
return;
|
|
164
|
+
this.ws = new WebSocket(this.url, {
|
|
165
|
+
headers: this.headers,
|
|
166
|
+
});
|
|
167
|
+
this.ws.binaryType = "nodebuffer";
|
|
168
|
+
this.ws.on("open", () => {
|
|
169
|
+
this.connected = true;
|
|
170
|
+
this.reconnectAttempt = 0;
|
|
171
|
+
this.replayBuffer();
|
|
172
|
+
});
|
|
173
|
+
this.ws.on("message", (data) => {
|
|
174
|
+
this.handleMessage(data);
|
|
175
|
+
});
|
|
176
|
+
this.ws.on("close", () => {
|
|
177
|
+
this.handleDisconnect();
|
|
178
|
+
});
|
|
179
|
+
this.ws.on("error", () => {
|
|
180
|
+
// Error is followed by close event, so we handle reconnect there
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
handleDisconnect() {
|
|
184
|
+
this.connected = false;
|
|
185
|
+
if (this.closed)
|
|
186
|
+
return;
|
|
187
|
+
// Reject pending requests that were in-flight (they won't get responses)
|
|
188
|
+
const lostError = new ConnectionLostError();
|
|
189
|
+
for (const [_id, pending] of this.pending) {
|
|
190
|
+
clearTimeout(pending.timeout);
|
|
191
|
+
pending.reject(lostError);
|
|
192
|
+
}
|
|
193
|
+
this.pending.clear();
|
|
194
|
+
// Schedule reconnect with exponential backoff
|
|
195
|
+
this.scheduleReconnect();
|
|
196
|
+
}
|
|
197
|
+
scheduleReconnect() {
|
|
198
|
+
if (this.closed)
|
|
199
|
+
return;
|
|
200
|
+
const delay = Math.min(this.initialDelayMs * this.backoffFactor ** this.reconnectAttempt, this.maxDelayMs);
|
|
201
|
+
this.reconnectAttempt++;
|
|
202
|
+
this.reconnectTimer = setTimeout(() => {
|
|
203
|
+
this.reconnectTimer = null;
|
|
204
|
+
this.connect();
|
|
205
|
+
}, delay);
|
|
206
|
+
}
|
|
207
|
+
// ─── Buffer Replay ──────────────────────────────────────────────────────
|
|
208
|
+
replayBuffer() {
|
|
209
|
+
const buffered = this.buffer.splice(0);
|
|
210
|
+
for (const entry of buffered) {
|
|
211
|
+
if (this.closed) {
|
|
212
|
+
entry.reject(new TransportClosedError());
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Decode the frame to get the ID for pending registration
|
|
216
|
+
const decoded = unpack(entry.frame);
|
|
217
|
+
if ("pipeline" in decoded && decoded.pipeline) {
|
|
218
|
+
// Pipeline frame
|
|
219
|
+
const _pipelineResolve = (data) => {
|
|
220
|
+
const resp = data;
|
|
221
|
+
entry.resolve(resp);
|
|
222
|
+
};
|
|
223
|
+
this.registerPipelinePending(decoded.id, (responses) => {
|
|
224
|
+
entry.resolve(responses);
|
|
225
|
+
}, entry.reject);
|
|
226
|
+
this.ws?.send(entry.frame);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// Single request frame
|
|
230
|
+
this.registerPending(decoded.id, entry.resolve, entry.reject);
|
|
231
|
+
this.ws?.send(entry.frame);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ─── Message Handling ───────────────────────────────────────────────────
|
|
236
|
+
handleMessage(data) {
|
|
237
|
+
let decoded;
|
|
238
|
+
try {
|
|
239
|
+
decoded = unpack(data);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Malformed response — ignore
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (typeof decoded !== "object" || decoded === null)
|
|
246
|
+
return;
|
|
247
|
+
const msg = decoded;
|
|
248
|
+
// Check if it's a pipeline response
|
|
249
|
+
if (msg.pipeline === true && Array.isArray(msg.responses)) {
|
|
250
|
+
const pipelineResp = msg;
|
|
251
|
+
const pending = this.pending.get(pipelineResp.id);
|
|
252
|
+
if (pending) {
|
|
253
|
+
clearTimeout(pending.timeout);
|
|
254
|
+
this.pending.delete(pipelineResp.id);
|
|
255
|
+
pending.resolve(pipelineResp.responses.map((r) => r.data));
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Single response
|
|
260
|
+
const response = msg;
|
|
261
|
+
if (typeof response.id !== "number")
|
|
262
|
+
return;
|
|
263
|
+
const pending = this.pending.get(response.id);
|
|
264
|
+
if (pending) {
|
|
265
|
+
clearTimeout(pending.timeout);
|
|
266
|
+
this.pending.delete(response.id);
|
|
267
|
+
if (response.status >= 400) {
|
|
268
|
+
const errData = response.data;
|
|
269
|
+
const errMsg = typeof errData === "object" && errData?.error
|
|
270
|
+
? errData.error
|
|
271
|
+
: `Request failed with status ${response.status}`;
|
|
272
|
+
pending.reject(new Error(errMsg));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
pending.resolve(response.data);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// ─── Pending Request Registration ──────────────────────────────────────
|
|
280
|
+
registerPending(id, resolve, reject) {
|
|
281
|
+
const timeout = setTimeout(() => {
|
|
282
|
+
this.pending.delete(id);
|
|
283
|
+
reject(new TimeoutError(id, this.timeoutMs));
|
|
284
|
+
}, this.timeoutMs);
|
|
285
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
286
|
+
}
|
|
287
|
+
registerPipelinePending(id, resolve, reject) {
|
|
288
|
+
const timeout = setTimeout(() => {
|
|
289
|
+
this.pending.delete(id);
|
|
290
|
+
reject(new TimeoutError(id, this.timeoutMs));
|
|
291
|
+
}, this.timeoutMs);
|
|
292
|
+
// Store with a wrapped resolve that handles the pipeline response format
|
|
293
|
+
this.pending.set(id, {
|
|
294
|
+
resolve: resolve,
|
|
295
|
+
reject,
|
|
296
|
+
timeout,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecodrix/erix-client",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Type-safe HTTP client for the erix-store in-memory data service. Zero dependencies — uses native fetch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,9 +30,13 @@
|
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|
|
32
32
|
},
|
|
33
|
-
"dependencies": {
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"msgpackr": "^1.11.0",
|
|
35
|
+
"ws": "^8.18.0"
|
|
36
|
+
},
|
|
34
37
|
"devDependencies": {
|
|
35
38
|
"@types/node": "^20.12.7",
|
|
39
|
+
"@types/ws": "^8.5.10",
|
|
36
40
|
"typescript": "^5.4.5"
|
|
37
41
|
},
|
|
38
42
|
"repository": {
|