@debros/network-ts-sdk 0.1.5 → 0.3.1

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,203 @@
1
+ import { HttpClient } from "../core/http";
2
+ import { SDKError } from "../errors";
3
+
4
+ export interface CacheGetRequest {
5
+ dmap: string;
6
+ key: string;
7
+ }
8
+
9
+ export interface CacheGetResponse {
10
+ key: string;
11
+ value: any;
12
+ dmap: string;
13
+ }
14
+
15
+ export interface CachePutRequest {
16
+ dmap: string;
17
+ key: string;
18
+ value: any;
19
+ ttl?: string; // Duration string like "1h", "30m"
20
+ }
21
+
22
+ export interface CachePutResponse {
23
+ status: string;
24
+ key: string;
25
+ dmap: string;
26
+ }
27
+
28
+ export interface CacheDeleteRequest {
29
+ dmap: string;
30
+ key: string;
31
+ }
32
+
33
+ export interface CacheDeleteResponse {
34
+ status: string;
35
+ key: string;
36
+ dmap: string;
37
+ }
38
+
39
+ export interface CacheMultiGetRequest {
40
+ dmap: string;
41
+ keys: string[];
42
+ }
43
+
44
+ export interface CacheMultiGetResponse {
45
+ results: Array<{
46
+ key: string;
47
+ value: any;
48
+ }>;
49
+ dmap: string;
50
+ }
51
+
52
+ export interface CacheScanRequest {
53
+ dmap: string;
54
+ match?: string; // Optional regex pattern
55
+ }
56
+
57
+ export interface CacheScanResponse {
58
+ keys: string[];
59
+ count: number;
60
+ dmap: string;
61
+ }
62
+
63
+ export interface CacheHealthResponse {
64
+ status: string;
65
+ service: string;
66
+ }
67
+
68
+ export class CacheClient {
69
+ private httpClient: HttpClient;
70
+
71
+ constructor(httpClient: HttpClient) {
72
+ this.httpClient = httpClient;
73
+ }
74
+
75
+ /**
76
+ * Check cache service health
77
+ */
78
+ async health(): Promise<CacheHealthResponse> {
79
+ return this.httpClient.get("/v1/cache/health");
80
+ }
81
+
82
+ /**
83
+ * Get a value from cache
84
+ * Returns null if the key is not found (cache miss/expired), which is normal behavior
85
+ */
86
+ async get(dmap: string, key: string): Promise<CacheGetResponse | null> {
87
+ try {
88
+ return await this.httpClient.post<CacheGetResponse>("/v1/cache/get", {
89
+ dmap,
90
+ key,
91
+ });
92
+ } catch (error) {
93
+ // Cache misses (404 or "key not found" messages) are normal behavior - return null instead of throwing
94
+ if (
95
+ error instanceof SDKError &&
96
+ (error.httpStatus === 404 ||
97
+ (error.httpStatus === 500 &&
98
+ error.message?.toLowerCase().includes("key not found")))
99
+ ) {
100
+ return null;
101
+ }
102
+ // Re-throw other errors (network issues, server errors, etc.)
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Put a value into cache
109
+ */
110
+ async put(
111
+ dmap: string,
112
+ key: string,
113
+ value: any,
114
+ ttl?: string
115
+ ): Promise<CachePutResponse> {
116
+ return this.httpClient.post<CachePutResponse>("/v1/cache/put", {
117
+ dmap,
118
+ key,
119
+ value,
120
+ ttl,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Delete a value from cache
126
+ */
127
+ async delete(dmap: string, key: string): Promise<CacheDeleteResponse> {
128
+ return this.httpClient.post<CacheDeleteResponse>("/v1/cache/delete", {
129
+ dmap,
130
+ key,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Get multiple values from cache in a single request
136
+ * Returns a map of key -> value (or null if not found)
137
+ * Gracefully handles 404 errors (endpoint not implemented) by returning empty results
138
+ */
139
+ async multiGet(
140
+ dmap: string,
141
+ keys: string[]
142
+ ): Promise<Map<string, any | null>> {
143
+ try {
144
+ if (keys.length === 0) {
145
+ return new Map();
146
+ }
147
+
148
+ const response = await this.httpClient.post<CacheMultiGetResponse>(
149
+ "/v1/cache/mget",
150
+ {
151
+ dmap,
152
+ keys,
153
+ }
154
+ );
155
+
156
+ // Convert array to Map
157
+ const resultMap = new Map<string, any | null>();
158
+
159
+ // First, mark all keys as null (cache miss)
160
+ keys.forEach((key) => {
161
+ resultMap.set(key, null);
162
+ });
163
+
164
+ // Then, update with found values
165
+ if (response.results) {
166
+ response.results.forEach(({ key, value }) => {
167
+ resultMap.set(key, value);
168
+ });
169
+ }
170
+
171
+ return resultMap;
172
+ } catch (error) {
173
+ // Handle 404 errors silently (endpoint not implemented on backend)
174
+ // This is expected behavior when the backend doesn't support multiGet yet
175
+ if (error instanceof SDKError && error.httpStatus === 404) {
176
+ // Return map with all nulls silently - caller can fall back to individual gets
177
+ const resultMap = new Map<string, any | null>();
178
+ keys.forEach((key) => {
179
+ resultMap.set(key, null);
180
+ });
181
+ return resultMap;
182
+ }
183
+
184
+ // Log and return empty results for other errors
185
+ const resultMap = new Map<string, any | null>();
186
+ keys.forEach((key) => {
187
+ resultMap.set(key, null);
188
+ });
189
+ console.error(`[CacheClient] Error in multiGet for ${dmap}:`, error);
190
+ return resultMap;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Scan keys in a distributed map, optionally matching a regex pattern
196
+ */
197
+ async scan(dmap: string, match?: string): Promise<CacheScanResponse> {
198
+ return this.httpClient.post<CacheScanResponse>("/v1/cache/scan", {
199
+ dmap,
200
+ match,
201
+ });
202
+ }
203
+ }
package/src/core/http.ts CHANGED
@@ -19,7 +19,7 @@ export class HttpClient {
19
19
 
20
20
  constructor(config: HttpClientConfig) {
21
21
  this.baseURL = config.baseURL.replace(/\/$/, "");
22
- this.timeout = config.timeout ?? 30000;
22
+ this.timeout = config.timeout ?? 60000; // Increased from 30s to 60s for pub/sub operations
23
23
  this.maxRetries = config.maxRetries ?? 3;
24
24
  this.retryDelayMs = config.retryDelayMs ?? 1000;
25
25
  this.fetch = config.fetch ?? globalThis.fetch;
@@ -27,20 +27,64 @@ export class HttpClient {
27
27
 
28
28
  setApiKey(apiKey?: string) {
29
29
  this.apiKey = apiKey;
30
- this.jwt = undefined;
30
+ // Don't clear JWT - allow both to coexist
31
31
  }
32
32
 
33
33
  setJwt(jwt?: string) {
34
34
  this.jwt = jwt;
35
- this.apiKey = undefined;
35
+ // Don't clear API key - allow both to coexist
36
+ if (typeof console !== "undefined") {
37
+ console.log(
38
+ "[HttpClient] JWT set:",
39
+ !!jwt,
40
+ "API key still present:",
41
+ !!this.apiKey
42
+ );
43
+ }
36
44
  }
37
45
 
38
- private getAuthHeaders(): Record<string, string> {
46
+ private getAuthHeaders(path: string): Record<string, string> {
39
47
  const headers: Record<string, string> = {};
40
- if (this.jwt) {
41
- headers["Authorization"] = `Bearer ${this.jwt}`;
42
- } else if (this.apiKey) {
43
- headers["X-API-Key"] = this.apiKey;
48
+
49
+ // For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context
50
+ // interfering with namespace-level authorization
51
+ const isDbOperation = path.includes("/v1/rqlite/");
52
+ const isPubSubOperation = path.includes("/v1/pubsub/");
53
+ const isProxyOperation = path.includes("/v1/proxy/");
54
+ const isCacheOperation = path.includes("/v1/cache/");
55
+
56
+ // For auth operations, prefer API key over JWT to ensure proper authentication
57
+ const isAuthOperation = path.includes("/v1/auth/");
58
+
59
+ if (
60
+ isDbOperation ||
61
+ isPubSubOperation ||
62
+ isProxyOperation ||
63
+ isCacheOperation
64
+ ) {
65
+ // For database/pubsub/proxy/cache operations: use only API key (preferred for namespace operations)
66
+ if (this.apiKey) {
67
+ headers["X-API-Key"] = this.apiKey;
68
+ } else if (this.jwt) {
69
+ // Fallback to JWT if no API key
70
+ headers["Authorization"] = `Bearer ${this.jwt}`;
71
+ }
72
+ } else if (isAuthOperation) {
73
+ // For auth operations: prefer API key over JWT (auth endpoints should use explicit API key)
74
+ if (this.apiKey) {
75
+ headers["X-API-Key"] = this.apiKey;
76
+ }
77
+ if (this.jwt) {
78
+ headers["Authorization"] = `Bearer ${this.jwt}`;
79
+ }
80
+ } else {
81
+ // For other operations: send both JWT and API key
82
+ if (this.jwt) {
83
+ headers["Authorization"] = `Bearer ${this.jwt}`;
84
+ }
85
+ if (this.apiKey) {
86
+ headers["X-API-Key"] = this.apiKey;
87
+ }
44
88
  }
45
89
  return headers;
46
90
  }
@@ -49,6 +93,10 @@ export class HttpClient {
49
93
  return this.jwt || this.apiKey;
50
94
  }
51
95
 
96
+ getApiKey(): string | undefined {
97
+ return this.apiKey;
98
+ }
99
+
52
100
  async request<T = any>(
53
101
  method: "GET" | "POST" | "PUT" | "DELETE",
54
102
  path: string,
@@ -56,8 +104,10 @@ export class HttpClient {
56
104
  body?: any;
57
105
  headers?: Record<string, string>;
58
106
  query?: Record<string, string | number | boolean>;
107
+ timeout?: number; // Per-request timeout override
59
108
  } = {}
60
109
  ): Promise<T> {
110
+ const startTime = performance.now(); // Track request start time
61
111
  const url = new URL(this.baseURL + path);
62
112
  if (options.query) {
63
113
  Object.entries(options.query).forEach(([key, value]) => {
@@ -67,27 +117,114 @@ export class HttpClient {
67
117
 
68
118
  const headers: Record<string, string> = {
69
119
  "Content-Type": "application/json",
70
- ...this.getAuthHeaders(),
120
+ ...this.getAuthHeaders(path),
71
121
  ...options.headers,
72
122
  };
73
123
 
124
+ const controller = new AbortController();
125
+ const requestTimeout = options.timeout ?? this.timeout; // Use override or default
126
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
127
+
74
128
  const fetchOptions: RequestInit = {
75
129
  method,
76
130
  headers,
77
- signal: AbortSignal.timeout(this.timeout),
131
+ signal: controller.signal,
78
132
  };
79
133
 
80
134
  if (options.body !== undefined) {
81
135
  fetchOptions.body = JSON.stringify(options.body);
82
136
  }
83
137
 
84
- return this.requestWithRetry(url.toString(), fetchOptions);
138
+ try {
139
+ const result = await this.requestWithRetry(
140
+ url.toString(),
141
+ fetchOptions,
142
+ 0,
143
+ startTime
144
+ );
145
+ const duration = performance.now() - startTime;
146
+ if (typeof console !== "undefined") {
147
+ console.log(
148
+ `[HttpClient] ${method} ${path} completed in ${duration.toFixed(2)}ms`
149
+ );
150
+ }
151
+ return result;
152
+ } catch (error) {
153
+ const duration = performance.now() - startTime;
154
+ if (typeof console !== "undefined") {
155
+ // Cache "key not found" (404 or error message) is expected behavior - don't log as error
156
+ const isCacheGetNotFound =
157
+ path === "/v1/cache/get" &&
158
+ error instanceof SDKError &&
159
+ (error.httpStatus === 404 ||
160
+ (error.httpStatus === 500 &&
161
+ error.message?.toLowerCase().includes("key not found")));
162
+
163
+ // "Not found" (404) for blocked_users is expected behavior - don't log as error
164
+ // This happens when checking if users are blocked (most users aren't blocked)
165
+ const isBlockedUsersNotFound =
166
+ path === "/v1/rqlite/find-one" &&
167
+ error instanceof SDKError &&
168
+ error.httpStatus === 404 &&
169
+ options.body &&
170
+ (() => {
171
+ try {
172
+ const body =
173
+ typeof options.body === "string"
174
+ ? JSON.parse(options.body)
175
+ : options.body;
176
+ return body.table === "blocked_users";
177
+ } catch {
178
+ return false;
179
+ }
180
+ })();
181
+
182
+ // "Not found" (404) for conversation_participants is expected behavior - don't log as error
183
+ // This happens when checking if a user is a participant (e.g., on first group join)
184
+ const isConversationParticipantNotFound =
185
+ path === "/v1/rqlite/find-one" &&
186
+ error instanceof SDKError &&
187
+ error.httpStatus === 404 &&
188
+ options.body &&
189
+ (() => {
190
+ try {
191
+ const body =
192
+ typeof options.body === "string"
193
+ ? JSON.parse(options.body)
194
+ : options.body;
195
+ return body.table === "conversation_participants";
196
+ } catch {
197
+ return false;
198
+ }
199
+ })();
200
+
201
+ if (
202
+ isCacheGetNotFound ||
203
+ isBlockedUsersNotFound ||
204
+ isConversationParticipantNotFound
205
+ ) {
206
+ // Log cache miss, non-blocked status, or non-participant status as debug/info, not error
207
+ // These are expected behaviors
208
+ } else {
209
+ console.error(
210
+ `[HttpClient] ${method} ${path} failed after ${duration.toFixed(
211
+ 2
212
+ )}ms:`,
213
+ error
214
+ );
215
+ }
216
+ }
217
+ throw error;
218
+ } finally {
219
+ clearTimeout(timeoutId);
220
+ }
85
221
  }
86
222
 
87
223
  private async requestWithRetry(
88
224
  url: string,
89
225
  options: RequestInit,
90
- attempt: number = 0
226
+ attempt: number = 0,
227
+ startTime?: number // Track start time for timing across retries
91
228
  ): Promise<any> {
92
229
  try {
93
230
  const response = await this.fetch(url, options);
@@ -116,7 +253,7 @@ export class HttpClient {
116
253
  await new Promise((resolve) =>
117
254
  setTimeout(resolve, this.retryDelayMs * (attempt + 1))
118
255
  );
119
- return this.requestWithRetry(url, options, attempt + 1);
256
+ return this.requestWithRetry(url, options, attempt + 1, startTime);
120
257
  }
121
258
  throw error;
122
259
  }
@@ -152,6 +289,104 @@ export class HttpClient {
152
289
  return this.request<T>("DELETE", path, options);
153
290
  }
154
291
 
292
+ /**
293
+ * Upload a file using multipart/form-data
294
+ * This is a special method for file uploads that bypasses JSON serialization
295
+ */
296
+ async uploadFile<T = any>(
297
+ path: string,
298
+ formData: FormData,
299
+ options?: {
300
+ timeout?: number;
301
+ }
302
+ ): Promise<T> {
303
+ const startTime = performance.now(); // Track upload start time
304
+ const url = new URL(this.baseURL + path);
305
+ const headers: Record<string, string> = {
306
+ ...this.getAuthHeaders(path),
307
+ // Don't set Content-Type - browser will set it with boundary
308
+ };
309
+
310
+ const controller = new AbortController();
311
+ const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads
312
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
313
+
314
+ const fetchOptions: RequestInit = {
315
+ method: "POST",
316
+ headers,
317
+ body: formData,
318
+ signal: controller.signal,
319
+ };
320
+
321
+ try {
322
+ const result = await this.requestWithRetry(
323
+ url.toString(),
324
+ fetchOptions,
325
+ 0,
326
+ startTime
327
+ );
328
+ const duration = performance.now() - startTime;
329
+ if (typeof console !== "undefined") {
330
+ console.log(
331
+ `[HttpClient] POST ${path} (upload) completed in ${duration.toFixed(
332
+ 2
333
+ )}ms`
334
+ );
335
+ }
336
+ return result;
337
+ } catch (error) {
338
+ const duration = performance.now() - startTime;
339
+ if (typeof console !== "undefined") {
340
+ console.error(
341
+ `[HttpClient] POST ${path} (upload) failed after ${duration.toFixed(
342
+ 2
343
+ )}ms:`,
344
+ error
345
+ );
346
+ }
347
+ throw error;
348
+ } finally {
349
+ clearTimeout(timeoutId);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get a binary response (returns Response object for streaming)
355
+ */
356
+ async getBinary(path: string): Promise<Response> {
357
+ const url = new URL(this.baseURL + path);
358
+ const headers: Record<string, string> = {
359
+ ...this.getAuthHeaders(path),
360
+ };
361
+
362
+ const controller = new AbortController();
363
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads
364
+
365
+ const fetchOptions: RequestInit = {
366
+ method: "GET",
367
+ headers,
368
+ signal: controller.signal,
369
+ };
370
+
371
+ try {
372
+ const response = await this.fetch(url.toString(), fetchOptions);
373
+ if (!response.ok) {
374
+ clearTimeout(timeoutId);
375
+ const error = await response.json().catch(() => ({
376
+ error: response.statusText,
377
+ }));
378
+ throw SDKError.fromResponse(response.status, error);
379
+ }
380
+ return response;
381
+ } catch (error) {
382
+ clearTimeout(timeoutId);
383
+ if (error instanceof SDKError) {
384
+ throw error;
385
+ }
386
+ throw error;
387
+ }
388
+ }
389
+
155
390
  getToken(): string | undefined {
156
391
  return this.getAuthToken();
157
392
  }