@debros/network-ts-sdk 0.4.2 → 0.6.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/src/core/http.ts CHANGED
@@ -1,11 +1,39 @@
1
1
  import { SDKError } from "../errors";
2
2
 
3
+ /**
4
+ * Context provided to the onNetworkError callback
5
+ */
6
+ export interface NetworkErrorContext {
7
+ method: "GET" | "POST" | "PUT" | "DELETE" | "WS";
8
+ path: string;
9
+ isRetry: boolean;
10
+ attempt: number;
11
+ }
12
+
13
+ /**
14
+ * Callback invoked when a network error occurs.
15
+ * Use this to trigger gateway failover or other error handling.
16
+ */
17
+ export type NetworkErrorCallback = (
18
+ error: SDKError,
19
+ context: NetworkErrorContext
20
+ ) => void;
21
+
3
22
  export interface HttpClientConfig {
4
23
  baseURL: string;
5
24
  timeout?: number;
6
25
  maxRetries?: number;
7
26
  retryDelayMs?: number;
8
27
  fetch?: typeof fetch;
28
+ /**
29
+ * Enable debug logging (includes full SQL queries and args). Default: false
30
+ */
31
+ debug?: boolean;
32
+ /**
33
+ * Callback invoked on network errors (after all retries exhausted).
34
+ * Use this to trigger gateway failover at the application layer.
35
+ */
36
+ onNetworkError?: NetworkErrorCallback;
9
37
  }
10
38
 
11
39
  /**
@@ -39,6 +67,8 @@ export class HttpClient {
39
67
  private fetch: typeof fetch;
40
68
  private apiKey?: string;
41
69
  private jwt?: string;
70
+ private debug: boolean;
71
+ private onNetworkError?: NetworkErrorCallback;
42
72
 
43
73
  constructor(config: HttpClientConfig) {
44
74
  this.baseURL = config.baseURL.replace(/\/$/, "");
@@ -47,6 +77,15 @@ export class HttpClient {
47
77
  this.retryDelayMs = config.retryDelayMs ?? 1000;
48
78
  // Use provided fetch or create one with proper TLS configuration for staging certificates
49
79
  this.fetch = config.fetch ?? createFetchWithTLSConfig();
80
+ this.debug = config.debug ?? false;
81
+ this.onNetworkError = config.onNetworkError;
82
+ }
83
+
84
+ /**
85
+ * Set the network error callback
86
+ */
87
+ setOnNetworkError(callback: NetworkErrorCallback | undefined): void {
88
+ this.onNetworkError = callback;
50
89
  }
51
90
 
52
91
  setApiKey(apiKey?: string) {
@@ -223,7 +262,7 @@ export class HttpClient {
223
262
  const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(
224
263
  2
225
264
  )}ms`;
226
- if (queryDetails) {
265
+ if (queryDetails && this.debug) {
227
266
  console.log(logMessage);
228
267
  console.log(`[HttpClient] ${queryDetails}`);
229
268
  } else {
@@ -253,11 +292,32 @@ export class HttpClient {
253
292
  2
254
293
  )}ms:`;
255
294
  console.error(errorMessage, error);
256
- if (queryDetails) {
295
+ if (queryDetails && this.debug) {
257
296
  console.error(`[HttpClient] ${queryDetails}`);
258
297
  }
259
298
  }
260
299
  }
300
+
301
+ // Call the network error callback if configured
302
+ // This allows the app to trigger gateway failover
303
+ if (this.onNetworkError) {
304
+ // Convert native errors (TypeError, AbortError) to SDKError for the callback
305
+ const sdkError =
306
+ error instanceof SDKError
307
+ ? error
308
+ : new SDKError(
309
+ error instanceof Error ? error.message : String(error),
310
+ 0, // httpStatus 0 indicates network-level failure
311
+ "NETWORK_ERROR"
312
+ );
313
+ this.onNetworkError(sdkError, {
314
+ method,
315
+ path,
316
+ isRetry: false,
317
+ attempt: this.maxRetries, // All retries exhausted
318
+ });
319
+ }
320
+
261
321
  throw error;
262
322
  } finally {
263
323
  clearTimeout(timeoutId);
@@ -397,6 +457,25 @@ export class HttpClient {
397
457
  error
398
458
  );
399
459
  }
460
+
461
+ // Call the network error callback if configured
462
+ if (this.onNetworkError) {
463
+ const sdkError =
464
+ error instanceof SDKError
465
+ ? error
466
+ : new SDKError(
467
+ error instanceof Error ? error.message : String(error),
468
+ 0,
469
+ "NETWORK_ERROR"
470
+ );
471
+ this.onNetworkError(sdkError, {
472
+ method: "POST",
473
+ path,
474
+ isRetry: false,
475
+ attempt: this.maxRetries,
476
+ });
477
+ }
478
+
400
479
  throw error;
401
480
  } finally {
402
481
  clearTimeout(timeoutId);
@@ -425,17 +504,33 @@ export class HttpClient {
425
504
  const response = await this.fetch(url.toString(), fetchOptions);
426
505
  if (!response.ok) {
427
506
  clearTimeout(timeoutId);
428
- const error = await response.json().catch(() => ({
507
+ const errorBody = await response.json().catch(() => ({
429
508
  error: response.statusText,
430
509
  }));
431
- throw SDKError.fromResponse(response.status, error);
510
+ throw SDKError.fromResponse(response.status, errorBody);
432
511
  }
433
512
  return response;
434
513
  } catch (error) {
435
514
  clearTimeout(timeoutId);
436
- if (error instanceof SDKError) {
437
- throw error;
515
+
516
+ // Call the network error callback if configured
517
+ if (this.onNetworkError) {
518
+ const sdkError =
519
+ error instanceof SDKError
520
+ ? error
521
+ : new SDKError(
522
+ error instanceof Error ? error.message : String(error),
523
+ 0,
524
+ "NETWORK_ERROR"
525
+ );
526
+ this.onNetworkError(sdkError, {
527
+ method: "GET",
528
+ path,
529
+ isRetry: false,
530
+ attempt: 0,
531
+ });
438
532
  }
533
+
439
534
  throw error;
440
535
  }
441
536
  }
@@ -0,0 +1,10 @@
1
+ export { HttpClient, type HttpClientConfig, type NetworkErrorCallback, type NetworkErrorContext } from "./http";
2
+ export { WSClient, type WSClientConfig } from "./ws";
3
+ export type { IHttpTransport, RequestOptions } from "./interfaces/IHttpTransport";
4
+ export type { IWebSocketClient } from "./interfaces/IWebSocketClient";
5
+ export type { IAuthStrategy, RequestContext } from "./interfaces/IAuthStrategy";
6
+ export type { IRetryPolicy } from "./interfaces/IRetryPolicy";
7
+ export { PathBasedAuthStrategy } from "./transport/AuthHeaderStrategy";
8
+ export { ExponentialBackoffRetryPolicy } from "./transport/RequestRetryPolicy";
9
+ export { RequestLogger } from "./transport/RequestLogger";
10
+ export { TLSConfiguration } from "./transport/TLSConfiguration";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Request context for authentication
3
+ */
4
+ export interface RequestContext {
5
+ path: string;
6
+ method: string;
7
+ }
8
+
9
+ /**
10
+ * Authentication strategy interface
11
+ * Provides abstraction for different authentication header strategies
12
+ */
13
+ export interface IAuthStrategy {
14
+ /**
15
+ * Get authentication headers for a request
16
+ */
17
+ getHeaders(context: RequestContext): Record<string, string>;
18
+
19
+ /**
20
+ * Set API key
21
+ */
22
+ setApiKey(apiKey?: string): void;
23
+
24
+ /**
25
+ * Set JWT token
26
+ */
27
+ setJwt(jwt?: string): void;
28
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * HTTP Request options
3
+ */
4
+ export interface RequestOptions {
5
+ headers?: Record<string, string>;
6
+ query?: Record<string, string | number | boolean>;
7
+ timeout?: number;
8
+ }
9
+
10
+ /**
11
+ * HTTP Transport abstraction interface
12
+ * Provides a testable abstraction layer for HTTP operations
13
+ */
14
+ export interface IHttpTransport {
15
+ /**
16
+ * Perform GET request
17
+ */
18
+ get<T = any>(path: string, options?: RequestOptions): Promise<T>;
19
+
20
+ /**
21
+ * Perform POST request
22
+ */
23
+ post<T = any>(path: string, body?: any, options?: RequestOptions): Promise<T>;
24
+
25
+ /**
26
+ * Perform PUT request
27
+ */
28
+ put<T = any>(path: string, body?: any, options?: RequestOptions): Promise<T>;
29
+
30
+ /**
31
+ * Perform DELETE request
32
+ */
33
+ delete<T = any>(path: string, options?: RequestOptions): Promise<T>;
34
+
35
+ /**
36
+ * Upload file using multipart/form-data
37
+ */
38
+ uploadFile<T = any>(
39
+ path: string,
40
+ formData: FormData,
41
+ options?: { timeout?: number }
42
+ ): Promise<T>;
43
+
44
+ /**
45
+ * Get binary response (returns Response object for streaming)
46
+ */
47
+ getBinary(path: string): Promise<Response>;
48
+
49
+ /**
50
+ * Get base URL
51
+ */
52
+ getBaseURL(): string;
53
+
54
+ /**
55
+ * Get API key
56
+ */
57
+ getApiKey(): string | undefined;
58
+
59
+ /**
60
+ * Get current token (JWT or API key)
61
+ */
62
+ getToken(): string | undefined;
63
+
64
+ /**
65
+ * Set API key for authentication
66
+ */
67
+ setApiKey(apiKey?: string): void;
68
+
69
+ /**
70
+ * Set JWT token for authentication
71
+ */
72
+ setJwt(jwt?: string): void;
73
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Retry policy interface
3
+ * Provides abstraction for retry logic and backoff strategies
4
+ */
5
+ export interface IRetryPolicy {
6
+ /**
7
+ * Determine if request should be retried
8
+ */
9
+ shouldRetry(error: any, attempt: number): boolean;
10
+
11
+ /**
12
+ * Get delay before next retry attempt (in milliseconds)
13
+ */
14
+ getDelay(attempt: number): number;
15
+
16
+ /**
17
+ * Get maximum number of retry attempts
18
+ */
19
+ getMaxRetries(): number;
20
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WebSocket Client abstraction interface
3
+ * Provides a testable abstraction layer for WebSocket operations
4
+ */
5
+ export interface IWebSocketClient {
6
+ /**
7
+ * Connect to WebSocket server
8
+ */
9
+ connect(): Promise<void>;
10
+
11
+ /**
12
+ * Close WebSocket connection
13
+ */
14
+ close(): void;
15
+
16
+ /**
17
+ * Send data through WebSocket
18
+ */
19
+ send(data: string): void;
20
+
21
+ /**
22
+ * Register message handler
23
+ */
24
+ onMessage(handler: (data: string) => void): void;
25
+
26
+ /**
27
+ * Unregister message handler
28
+ */
29
+ offMessage(handler: (data: string) => void): void;
30
+
31
+ /**
32
+ * Register error handler
33
+ */
34
+ onError(handler: (error: Error) => void): void;
35
+
36
+ /**
37
+ * Unregister error handler
38
+ */
39
+ offError(handler: (error: Error) => void): void;
40
+
41
+ /**
42
+ * Register close handler
43
+ */
44
+ onClose(handler: () => void): void;
45
+
46
+ /**
47
+ * Unregister close handler
48
+ */
49
+ offClose(handler: () => void): void;
50
+
51
+ /**
52
+ * Check if WebSocket is connected
53
+ */
54
+ isConnected(): boolean;
55
+
56
+ /**
57
+ * Get WebSocket URL
58
+ */
59
+ get url(): string;
60
+ }
@@ -0,0 +1,4 @@
1
+ export type { IHttpTransport, RequestOptions } from "./IHttpTransport";
2
+ export type { IWebSocketClient } from "./IWebSocketClient";
3
+ export type { IAuthStrategy, RequestContext } from "./IAuthStrategy";
4
+ export type { IRetryPolicy } from "./IRetryPolicy";
@@ -0,0 +1,108 @@
1
+ import type { IAuthStrategy, RequestContext } from "../interfaces/IAuthStrategy";
2
+
3
+ /**
4
+ * Authentication type for different operations
5
+ */
6
+ type AuthType = "api-key-only" | "api-key-preferred" | "jwt-preferred" | "both";
7
+
8
+ /**
9
+ * Path-based authentication strategy
10
+ * Determines which auth credentials to use based on the request path
11
+ */
12
+ export class PathBasedAuthStrategy implements IAuthStrategy {
13
+ private apiKey?: string;
14
+ private jwt?: string;
15
+
16
+ /**
17
+ * Mapping of path patterns to auth types
18
+ */
19
+ private readonly authRules: Array<{ pattern: string; type: AuthType }> = [
20
+ // Database, PubSub, Proxy, Cache: prefer API key
21
+ { pattern: "/v1/rqlite/", type: "api-key-only" },
22
+ { pattern: "/v1/pubsub/", type: "api-key-only" },
23
+ { pattern: "/v1/proxy/", type: "api-key-only" },
24
+ { pattern: "/v1/cache/", type: "api-key-only" },
25
+ // Auth operations: prefer API key
26
+ { pattern: "/v1/auth/", type: "api-key-preferred" },
27
+ ];
28
+
29
+ constructor(apiKey?: string, jwt?: string) {
30
+ this.apiKey = apiKey;
31
+ this.jwt = jwt;
32
+ }
33
+
34
+ /**
35
+ * Get authentication headers for a request
36
+ */
37
+ getHeaders(context: RequestContext): Record<string, string> {
38
+ const headers: Record<string, string> = {};
39
+ const authType = this.detectAuthType(context.path);
40
+
41
+ switch (authType) {
42
+ case "api-key-only":
43
+ if (this.apiKey) {
44
+ headers["X-API-Key"] = this.apiKey;
45
+ } else if (this.jwt) {
46
+ // Fallback to JWT if no API key
47
+ headers["Authorization"] = `Bearer ${this.jwt}`;
48
+ }
49
+ break;
50
+
51
+ case "api-key-preferred":
52
+ if (this.apiKey) {
53
+ headers["X-API-Key"] = this.apiKey;
54
+ }
55
+ if (this.jwt) {
56
+ headers["Authorization"] = `Bearer ${this.jwt}`;
57
+ }
58
+ break;
59
+
60
+ case "jwt-preferred":
61
+ if (this.jwt) {
62
+ headers["Authorization"] = `Bearer ${this.jwt}`;
63
+ }
64
+ if (this.apiKey) {
65
+ headers["X-API-Key"] = this.apiKey;
66
+ }
67
+ break;
68
+
69
+ case "both":
70
+ if (this.jwt) {
71
+ headers["Authorization"] = `Bearer ${this.jwt}`;
72
+ }
73
+ if (this.apiKey) {
74
+ headers["X-API-Key"] = this.apiKey;
75
+ }
76
+ break;
77
+ }
78
+
79
+ return headers;
80
+ }
81
+
82
+ /**
83
+ * Set API key
84
+ */
85
+ setApiKey(apiKey?: string): void {
86
+ this.apiKey = apiKey;
87
+ }
88
+
89
+ /**
90
+ * Set JWT token
91
+ */
92
+ setJwt(jwt?: string): void {
93
+ this.jwt = jwt;
94
+ }
95
+
96
+ /**
97
+ * Detect auth type based on path
98
+ */
99
+ private detectAuthType(path: string): AuthType {
100
+ for (const rule of this.authRules) {
101
+ if (path.includes(rule.pattern)) {
102
+ return rule.type;
103
+ }
104
+ }
105
+ // Default: send both if available
106
+ return "both";
107
+ }
108
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Request logger for debugging HTTP operations
3
+ */
4
+ export class RequestLogger {
5
+ private readonly debug: boolean;
6
+
7
+ constructor(debug: boolean = false) {
8
+ this.debug = debug;
9
+ }
10
+
11
+ /**
12
+ * Log successful request
13
+ */
14
+ logSuccess(
15
+ method: string,
16
+ path: string,
17
+ duration: number,
18
+ queryDetails?: string
19
+ ): void {
20
+ if (typeof console === "undefined") return;
21
+
22
+ const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(2)}ms`;
23
+
24
+ if (queryDetails && this.debug) {
25
+ console.log(logMessage);
26
+ console.log(`[HttpClient] ${queryDetails}`);
27
+ } else {
28
+ console.log(logMessage);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Log failed request
34
+ */
35
+ logError(
36
+ method: string,
37
+ path: string,
38
+ duration: number,
39
+ error: any,
40
+ queryDetails?: string
41
+ ): void {
42
+ if (typeof console === "undefined") return;
43
+
44
+ // Special handling for 404 on find-one (expected behavior)
45
+ const is404FindOne =
46
+ path === "/v1/rqlite/find-one" &&
47
+ error?.httpStatus === 404;
48
+
49
+ if (is404FindOne) {
50
+ console.warn(
51
+ `[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(2)}ms (expected for optional lookups)`
52
+ );
53
+ return;
54
+ }
55
+
56
+ const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(2)}ms:`;
57
+ console.error(errorMessage, error);
58
+
59
+ if (queryDetails && this.debug) {
60
+ console.error(`[HttpClient] ${queryDetails}`);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Extract query details from request for logging
66
+ */
67
+ extractQueryDetails(path: string, body?: any): string | null {
68
+ if (!this.debug) return null;
69
+
70
+ const isRqliteOperation = path.includes("/v1/rqlite/");
71
+ if (!isRqliteOperation || !body) return null;
72
+
73
+ try {
74
+ const parsedBody = typeof body === "string" ? JSON.parse(body) : body;
75
+
76
+ // Direct SQL query
77
+ if (parsedBody.sql) {
78
+ let details = `SQL: ${parsedBody.sql}`;
79
+ if (parsedBody.args && parsedBody.args.length > 0) {
80
+ details += ` | Args: [${parsedBody.args
81
+ .map((a: any) => (typeof a === "string" ? `"${a}"` : a))
82
+ .join(", ")}]`;
83
+ }
84
+ return details;
85
+ }
86
+
87
+ // Table-based query
88
+ if (parsedBody.table) {
89
+ let details = `Table: ${parsedBody.table}`;
90
+ if (parsedBody.criteria && Object.keys(parsedBody.criteria).length > 0) {
91
+ details += ` | Criteria: ${JSON.stringify(parsedBody.criteria)}`;
92
+ }
93
+ if (parsedBody.options) {
94
+ details += ` | Options: ${JSON.stringify(parsedBody.options)}`;
95
+ }
96
+ if (parsedBody.select) {
97
+ details += ` | Select: ${JSON.stringify(parsedBody.select)}`;
98
+ }
99
+ if (parsedBody.where) {
100
+ details += ` | Where: ${JSON.stringify(parsedBody.where)}`;
101
+ }
102
+ if (parsedBody.limit) {
103
+ details += ` | Limit: ${parsedBody.limit}`;
104
+ }
105
+ if (parsedBody.offset) {
106
+ details += ` | Offset: ${parsedBody.offset}`;
107
+ }
108
+ return details;
109
+ }
110
+ } catch {
111
+ // Failed to parse, ignore
112
+ }
113
+
114
+ return null;
115
+ }
116
+ }
@@ -0,0 +1,53 @@
1
+ import type { IRetryPolicy } from "../interfaces/IRetryPolicy";
2
+ import { SDKError } from "../../errors";
3
+
4
+ /**
5
+ * Exponential backoff retry policy
6
+ * Retries failed requests with increasing delays
7
+ */
8
+ export class ExponentialBackoffRetryPolicy implements IRetryPolicy {
9
+ private readonly maxRetries: number;
10
+ private readonly baseDelayMs: number;
11
+
12
+ /**
13
+ * HTTP status codes that should trigger a retry
14
+ */
15
+ private readonly retryableStatusCodes = [408, 429, 500, 502, 503, 504];
16
+
17
+ constructor(maxRetries: number = 3, baseDelayMs: number = 1000) {
18
+ this.maxRetries = maxRetries;
19
+ this.baseDelayMs = baseDelayMs;
20
+ }
21
+
22
+ /**
23
+ * Determine if request should be retried
24
+ */
25
+ shouldRetry(error: any, attempt: number): boolean {
26
+ // Don't retry if max attempts reached
27
+ if (attempt >= this.maxRetries) {
28
+ return false;
29
+ }
30
+
31
+ // Retry on retryable HTTP errors
32
+ if (error instanceof SDKError) {
33
+ return this.retryableStatusCodes.includes(error.httpStatus);
34
+ }
35
+
36
+ // Don't retry other errors
37
+ return false;
38
+ }
39
+
40
+ /**
41
+ * Get delay before next retry (exponential backoff)
42
+ */
43
+ getDelay(attempt: number): number {
44
+ return this.baseDelayMs * (attempt + 1);
45
+ }
46
+
47
+ /**
48
+ * Get maximum number of retry attempts
49
+ */
50
+ getMaxRetries(): number {
51
+ return this.maxRetries;
52
+ }
53
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * TLS Configuration for development/staging environments
3
+ *
4
+ * WARNING: Only use this in development/testing environments!
5
+ * DO NOT disable certificate validation in production.
6
+ */
7
+ export class TLSConfiguration {
8
+ /**
9
+ * Create fetch function with proper TLS configuration
10
+ */
11
+ static createFetchWithTLSConfig(): typeof fetch {
12
+ // Only allow insecure TLS in development
13
+ if (this.shouldAllowInsecure()) {
14
+ this.configureInsecureTLS();
15
+ }
16
+
17
+ return globalThis.fetch;
18
+ }
19
+
20
+ /**
21
+ * Check if insecure TLS should be allowed
22
+ */
23
+ private static shouldAllowInsecure(): boolean {
24
+ // Check if we're in Node.js environment
25
+ if (typeof process === "undefined" || !process.versions?.node) {
26
+ return false;
27
+ }
28
+
29
+ // Only allow in non-production with explicit flag
30
+ const isProduction = process.env.NODE_ENV === "production";
31
+ const allowInsecure = process.env.DEBROS_ALLOW_INSECURE_TLS === "true";
32
+
33
+ return !isProduction && allowInsecure;
34
+ }
35
+
36
+ /**
37
+ * Configure Node.js to allow insecure TLS
38
+ * WARNING: Only call in development!
39
+ */
40
+ private static configureInsecureTLS(): void {
41
+ if (typeof process !== "undefined" && process.env) {
42
+ // Allow self-signed/staging certificates for development
43
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
44
+
45
+ if (typeof console !== "undefined") {
46
+ console.warn(
47
+ "[TLSConfiguration] WARNING: TLS certificate validation disabled for development. " +
48
+ "DO NOT use in production!"
49
+ );
50
+ }
51
+ }
52
+ }
53
+ }