@ecodrix/erix-client 1.0.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,7 +1,7 @@
1
1
  {
2
2
  "name": "@ecodrix/erix-client",
3
- "version": "1.0.5",
4
- "description": "Type-safe HTTP client for the erix-store in-memory data service. Zero dependencies — uses native fetch.",
3
+ "version": "1.3.0",
4
+ "description": "Type-safe HTTP client for ERIX Store — serverless cache, queues, and real-time messaging. Zero dependencies, uses native fetch.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
22
22
  "rate-limit",
23
23
  "pubsub",
24
24
  "ecodrix",
25
- "redis-alternative",
25
+ "serverless-data",
26
26
  "in-memory"
27
27
  ],
28
28
  "author": "dhanesh125",
@@ -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": {