@debros/network-ts-sdk 0.2.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.
- package/dist/index.d.ts +209 -1
- package/dist/index.js +438 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cache/client.ts +203 -0
- package/src/core/http.ts +202 -36
- package/src/db/repository.ts +6 -2
- package/src/index.ts +29 -1
- package/src/storage/client.ts +270 -0
package/src/core/http.ts
CHANGED
|
@@ -28,14 +28,6 @@ export class HttpClient {
|
|
|
28
28
|
setApiKey(apiKey?: string) {
|
|
29
29
|
this.apiKey = apiKey;
|
|
30
30
|
// Don't clear JWT - allow both to coexist
|
|
31
|
-
if (typeof console !== "undefined") {
|
|
32
|
-
console.log(
|
|
33
|
-
"[HttpClient] API key set:",
|
|
34
|
-
!!apiKey,
|
|
35
|
-
"JWT still present:",
|
|
36
|
-
!!this.jwt
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
31
|
}
|
|
40
32
|
|
|
41
33
|
setJwt(jwt?: string) {
|
|
@@ -54,22 +46,39 @@ export class HttpClient {
|
|
|
54
46
|
private getAuthHeaders(path: string): Record<string, string> {
|
|
55
47
|
const headers: Record<string, string> = {};
|
|
56
48
|
|
|
57
|
-
// For database, pubsub, and
|
|
49
|
+
// For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context
|
|
58
50
|
// interfering with namespace-level authorization
|
|
59
51
|
const isDbOperation = path.includes("/v1/rqlite/");
|
|
60
52
|
const isPubSubOperation = path.includes("/v1/pubsub/");
|
|
61
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/");
|
|
62
58
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
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)
|
|
65
66
|
if (this.apiKey) {
|
|
66
67
|
headers["X-API-Key"] = this.apiKey;
|
|
67
68
|
} else if (this.jwt) {
|
|
68
69
|
// Fallback to JWT if no API key
|
|
69
70
|
headers["Authorization"] = `Bearer ${this.jwt}`;
|
|
70
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
|
+
}
|
|
71
80
|
} else {
|
|
72
|
-
// For
|
|
81
|
+
// For other operations: send both JWT and API key
|
|
73
82
|
if (this.jwt) {
|
|
74
83
|
headers["Authorization"] = `Bearer ${this.jwt}`;
|
|
75
84
|
}
|
|
@@ -98,6 +107,7 @@ export class HttpClient {
|
|
|
98
107
|
timeout?: number; // Per-request timeout override
|
|
99
108
|
} = {}
|
|
100
109
|
): Promise<T> {
|
|
110
|
+
const startTime = performance.now(); // Track request start time
|
|
101
111
|
const url = new URL(this.baseURL + path);
|
|
102
112
|
if (options.query) {
|
|
103
113
|
Object.entries(options.query).forEach(([key, value]) => {
|
|
@@ -111,27 +121,6 @@ export class HttpClient {
|
|
|
111
121
|
...options.headers,
|
|
112
122
|
};
|
|
113
123
|
|
|
114
|
-
// Debug: Log headers being sent
|
|
115
|
-
if (
|
|
116
|
-
typeof console !== "undefined" &&
|
|
117
|
-
(path.includes("/db/") ||
|
|
118
|
-
path.includes("/query") ||
|
|
119
|
-
path.includes("/auth/") ||
|
|
120
|
-
path.includes("/pubsub/") ||
|
|
121
|
-
path.includes("/proxy/"))
|
|
122
|
-
) {
|
|
123
|
-
console.log("[HttpClient] Request headers for", path, {
|
|
124
|
-
hasAuth: !!headers["Authorization"],
|
|
125
|
-
hasApiKey: !!headers["X-API-Key"],
|
|
126
|
-
authPrefix: headers["Authorization"]
|
|
127
|
-
? headers["Authorization"].substring(0, 20)
|
|
128
|
-
: "none",
|
|
129
|
-
apiKeyPrefix: headers["X-API-Key"]
|
|
130
|
-
? headers["X-API-Key"].substring(0, 20)
|
|
131
|
-
: "none",
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
124
|
const controller = new AbortController();
|
|
136
125
|
const requestTimeout = options.timeout ?? this.timeout; // Use override or default
|
|
137
126
|
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
|
|
@@ -147,7 +136,85 @@ export class HttpClient {
|
|
|
147
136
|
}
|
|
148
137
|
|
|
149
138
|
try {
|
|
150
|
-
|
|
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;
|
|
151
218
|
} finally {
|
|
152
219
|
clearTimeout(timeoutId);
|
|
153
220
|
}
|
|
@@ -156,7 +223,8 @@ export class HttpClient {
|
|
|
156
223
|
private async requestWithRetry(
|
|
157
224
|
url: string,
|
|
158
225
|
options: RequestInit,
|
|
159
|
-
attempt: number = 0
|
|
226
|
+
attempt: number = 0,
|
|
227
|
+
startTime?: number // Track start time for timing across retries
|
|
160
228
|
): Promise<any> {
|
|
161
229
|
try {
|
|
162
230
|
const response = await this.fetch(url, options);
|
|
@@ -185,7 +253,7 @@ export class HttpClient {
|
|
|
185
253
|
await new Promise((resolve) =>
|
|
186
254
|
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
|
|
187
255
|
);
|
|
188
|
-
return this.requestWithRetry(url, options, attempt + 1);
|
|
256
|
+
return this.requestWithRetry(url, options, attempt + 1, startTime);
|
|
189
257
|
}
|
|
190
258
|
throw error;
|
|
191
259
|
}
|
|
@@ -221,6 +289,104 @@ export class HttpClient {
|
|
|
221
289
|
return this.request<T>("DELETE", path, options);
|
|
222
290
|
}
|
|
223
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
|
+
|
|
224
390
|
getToken(): string | undefined {
|
|
225
391
|
return this.getAuthToken();
|
|
226
392
|
}
|
package/src/db/repository.ts
CHANGED
|
@@ -98,7 +98,9 @@ export class Repository<T extends Record<string, any>> {
|
|
|
98
98
|
private buildInsertSql(entity: T): string {
|
|
99
99
|
const columns = Object.keys(entity).filter((k) => entity[k] !== undefined);
|
|
100
100
|
const placeholders = columns.map(() => "?").join(", ");
|
|
101
|
-
return `INSERT INTO ${this.tableName} (${columns.join(
|
|
101
|
+
return `INSERT INTO ${this.tableName} (${columns.join(
|
|
102
|
+
", "
|
|
103
|
+
)}) VALUES (${placeholders})`;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
private buildInsertArgs(entity: T): any[] {
|
|
@@ -111,7 +113,9 @@ export class Repository<T extends Record<string, any>> {
|
|
|
111
113
|
const columns = Object.keys(entity)
|
|
112
114
|
.filter((k) => entity[k] !== undefined && k !== this.primaryKey)
|
|
113
115
|
.map((k) => `${k} = ?`);
|
|
114
|
-
return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${
|
|
116
|
+
return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${
|
|
117
|
+
this.primaryKey
|
|
118
|
+
} = ?`;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
private buildUpdateArgs(entity: T): any[] {
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { AuthClient } from "./auth/client";
|
|
|
3
3
|
import { DBClient } from "./db/client";
|
|
4
4
|
import { PubSubClient } from "./pubsub/client";
|
|
5
5
|
import { NetworkClient } from "./network/client";
|
|
6
|
+
import { CacheClient } from "./cache/client";
|
|
7
|
+
import { StorageClient } from "./storage/client";
|
|
6
8
|
import { WSClientConfig } from "./core/ws";
|
|
7
9
|
import {
|
|
8
10
|
StorageAdapter,
|
|
@@ -23,6 +25,8 @@ export interface Client {
|
|
|
23
25
|
db: DBClient;
|
|
24
26
|
pubsub: PubSubClient;
|
|
25
27
|
network: NetworkClient;
|
|
28
|
+
cache: CacheClient;
|
|
29
|
+
storage: StorageClient;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function createClient(config: ClientConfig): Client {
|
|
@@ -52,16 +56,19 @@ export function createClient(config: ClientConfig): Client {
|
|
|
52
56
|
wsURL,
|
|
53
57
|
});
|
|
54
58
|
const network = new NetworkClient(httpClient);
|
|
59
|
+
const cache = new CacheClient(httpClient);
|
|
60
|
+
const storage = new StorageClient(httpClient);
|
|
55
61
|
|
|
56
62
|
return {
|
|
57
63
|
auth,
|
|
58
64
|
db,
|
|
59
65
|
pubsub,
|
|
60
66
|
network,
|
|
67
|
+
cache,
|
|
68
|
+
storage,
|
|
61
69
|
};
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
// Re-exports
|
|
65
72
|
export { HttpClient } from "./core/http";
|
|
66
73
|
export { WSClient } from "./core/ws";
|
|
67
74
|
export { AuthClient } from "./auth/client";
|
|
@@ -70,6 +77,8 @@ export { QueryBuilder } from "./db/qb";
|
|
|
70
77
|
export { Repository } from "./db/repository";
|
|
71
78
|
export { PubSubClient, Subscription } from "./pubsub/client";
|
|
72
79
|
export { NetworkClient } from "./network/client";
|
|
80
|
+
export { CacheClient } from "./cache/client";
|
|
81
|
+
export { StorageClient } from "./storage/client";
|
|
73
82
|
export { SDKError } from "./errors";
|
|
74
83
|
export { MemoryStorage, LocalStorageAdapter } from "./auth/types";
|
|
75
84
|
export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types";
|
|
@@ -86,3 +95,22 @@ export type {
|
|
|
86
95
|
ProxyRequest,
|
|
87
96
|
ProxyResponse,
|
|
88
97
|
} from "./network/client";
|
|
98
|
+
export type {
|
|
99
|
+
CacheGetRequest,
|
|
100
|
+
CacheGetResponse,
|
|
101
|
+
CachePutRequest,
|
|
102
|
+
CachePutResponse,
|
|
103
|
+
CacheDeleteRequest,
|
|
104
|
+
CacheDeleteResponse,
|
|
105
|
+
CacheMultiGetRequest,
|
|
106
|
+
CacheMultiGetResponse,
|
|
107
|
+
CacheScanRequest,
|
|
108
|
+
CacheScanResponse,
|
|
109
|
+
CacheHealthResponse,
|
|
110
|
+
} from "./cache/client";
|
|
111
|
+
export type {
|
|
112
|
+
StorageUploadResponse,
|
|
113
|
+
StoragePinRequest,
|
|
114
|
+
StoragePinResponse,
|
|
115
|
+
StorageStatus,
|
|
116
|
+
} from "./storage/client";
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { HttpClient } from "../core/http";
|
|
2
|
+
|
|
3
|
+
export interface StorageUploadResponse {
|
|
4
|
+
cid: string;
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StoragePinRequest {
|
|
10
|
+
cid: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StoragePinResponse {
|
|
15
|
+
cid: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StorageStatus {
|
|
20
|
+
cid: string;
|
|
21
|
+
name: string;
|
|
22
|
+
status: string; // "pinned", "pinning", "queued", "unpinned", "error"
|
|
23
|
+
replication_min: number;
|
|
24
|
+
replication_max: number;
|
|
25
|
+
replication_factor: number;
|
|
26
|
+
peers: string[];
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class StorageClient {
|
|
31
|
+
private httpClient: HttpClient;
|
|
32
|
+
|
|
33
|
+
constructor(httpClient: HttpClient) {
|
|
34
|
+
this.httpClient = httpClient;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Upload content to IPFS and optionally pin it.
|
|
39
|
+
* Supports both File objects (browser) and Buffer/ReadableStream (Node.js).
|
|
40
|
+
*
|
|
41
|
+
* @param file - File to upload (File, Blob, or Buffer)
|
|
42
|
+
* @param name - Optional filename
|
|
43
|
+
* @param options - Optional upload options
|
|
44
|
+
* @param options.pin - Whether to pin the content (default: true). Pinning happens asynchronously on the backend.
|
|
45
|
+
* @returns Upload result with CID
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* // Browser
|
|
50
|
+
* const fileInput = document.querySelector('input[type="file"]');
|
|
51
|
+
* const file = fileInput.files[0];
|
|
52
|
+
* const result = await client.storage.upload(file, file.name);
|
|
53
|
+
* console.log(result.cid);
|
|
54
|
+
*
|
|
55
|
+
* // Node.js
|
|
56
|
+
* const fs = require('fs');
|
|
57
|
+
* const fileBuffer = fs.readFileSync('image.jpg');
|
|
58
|
+
* const result = await client.storage.upload(fileBuffer, 'image.jpg', { pin: true });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
async upload(
|
|
62
|
+
file: File | Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>,
|
|
63
|
+
name?: string,
|
|
64
|
+
options?: {
|
|
65
|
+
pin?: boolean;
|
|
66
|
+
}
|
|
67
|
+
): Promise<StorageUploadResponse> {
|
|
68
|
+
// Create FormData for multipart upload
|
|
69
|
+
const formData = new FormData();
|
|
70
|
+
|
|
71
|
+
// Handle different input types
|
|
72
|
+
if (file instanceof File) {
|
|
73
|
+
formData.append("file", file);
|
|
74
|
+
} else if (file instanceof Blob) {
|
|
75
|
+
formData.append("file", file, name);
|
|
76
|
+
} else if (file instanceof ArrayBuffer) {
|
|
77
|
+
const blob = new Blob([file]);
|
|
78
|
+
formData.append("file", blob, name);
|
|
79
|
+
} else if (file instanceof Uint8Array) {
|
|
80
|
+
// Convert Uint8Array to ArrayBuffer for Blob constructor
|
|
81
|
+
const buffer = file.buffer.slice(
|
|
82
|
+
file.byteOffset,
|
|
83
|
+
file.byteOffset + file.byteLength
|
|
84
|
+
) as ArrayBuffer;
|
|
85
|
+
const blob = new Blob([buffer], { type: "application/octet-stream" });
|
|
86
|
+
formData.append("file", blob, name);
|
|
87
|
+
} else if (file instanceof ReadableStream) {
|
|
88
|
+
// For ReadableStream, we need to read it into a blob first
|
|
89
|
+
// This is a limitation - in practice, pass File/Blob/Buffer
|
|
90
|
+
const chunks: ArrayBuffer[] = [];
|
|
91
|
+
const reader = file.getReader();
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
const buffer = value.buffer.slice(
|
|
96
|
+
value.byteOffset,
|
|
97
|
+
value.byteOffset + value.byteLength
|
|
98
|
+
) as ArrayBuffer;
|
|
99
|
+
chunks.push(buffer);
|
|
100
|
+
}
|
|
101
|
+
const blob = new Blob(chunks);
|
|
102
|
+
formData.append("file", blob, name);
|
|
103
|
+
} else {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"Unsupported file type. Use File, Blob, ArrayBuffer, Uint8Array, or ReadableStream."
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Add pin flag (default: true)
|
|
110
|
+
const shouldPin = options?.pin !== false; // Default to true
|
|
111
|
+
formData.append("pin", shouldPin ? "true" : "false");
|
|
112
|
+
|
|
113
|
+
return this.httpClient.uploadFile<StorageUploadResponse>(
|
|
114
|
+
"/v1/storage/upload",
|
|
115
|
+
formData,
|
|
116
|
+
{ timeout: 300000 } // 5 minute timeout for large files
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pin an existing CID
|
|
122
|
+
*
|
|
123
|
+
* @param cid - Content ID to pin
|
|
124
|
+
* @param name - Optional name for the pin
|
|
125
|
+
* @returns Pin result
|
|
126
|
+
*/
|
|
127
|
+
async pin(cid: string, name?: string): Promise<StoragePinResponse> {
|
|
128
|
+
return this.httpClient.post<StoragePinResponse>("/v1/storage/pin", {
|
|
129
|
+
cid,
|
|
130
|
+
name,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the pin status for a CID
|
|
136
|
+
*
|
|
137
|
+
* @param cid - Content ID to check
|
|
138
|
+
* @returns Pin status information
|
|
139
|
+
*/
|
|
140
|
+
async status(cid: string): Promise<StorageStatus> {
|
|
141
|
+
return this.httpClient.get<StorageStatus>(`/v1/storage/status/${cid}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Retrieve content from IPFS by CID
|
|
146
|
+
*
|
|
147
|
+
* @param cid - Content ID to retrieve
|
|
148
|
+
* @returns ReadableStream of the content
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* const stream = await client.storage.get(cid);
|
|
153
|
+
* const reader = stream.getReader();
|
|
154
|
+
* while (true) {
|
|
155
|
+
* const { done, value } = await reader.read();
|
|
156
|
+
* if (done) break;
|
|
157
|
+
* // Process chunk
|
|
158
|
+
* }
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
async get(cid: string): Promise<ReadableStream<Uint8Array>> {
|
|
162
|
+
// Retry logic for content retrieval - content may not be immediately available
|
|
163
|
+
// after upload due to eventual consistency in IPFS Cluster
|
|
164
|
+
// IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
|
|
165
|
+
const maxAttempts = 8;
|
|
166
|
+
let lastError: Error | null = null;
|
|
167
|
+
|
|
168
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
169
|
+
try {
|
|
170
|
+
const response = await this.httpClient.getBinary(
|
|
171
|
+
`/v1/storage/get/${cid}`
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!response.body) {
|
|
175
|
+
throw new Error("Response body is null");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return response.body;
|
|
179
|
+
} catch (error: any) {
|
|
180
|
+
lastError = error;
|
|
181
|
+
|
|
182
|
+
// Check if this is a 404 error (content not found)
|
|
183
|
+
const isNotFound =
|
|
184
|
+
error?.httpStatus === 404 ||
|
|
185
|
+
error?.message?.includes("not found") ||
|
|
186
|
+
error?.message?.includes("404");
|
|
187
|
+
|
|
188
|
+
// If it's not a 404 error, or this is the last attempt, give up
|
|
189
|
+
if (!isNotFound || attempt === maxAttempts) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
|
|
194
|
+
// This gives up to ~12 seconds total wait time, covering typical pin completion
|
|
195
|
+
const backoffMs = attempt * 2500;
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// This should never be reached, but TypeScript needs it
|
|
201
|
+
throw lastError || new Error("Failed to retrieve content");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Retrieve content from IPFS by CID and return the full Response object
|
|
206
|
+
* Useful when you need access to response headers (e.g., content-length)
|
|
207
|
+
*
|
|
208
|
+
* @param cid - Content ID to retrieve
|
|
209
|
+
* @returns Response object with body stream and headers
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* const response = await client.storage.getBinary(cid);
|
|
214
|
+
* const contentLength = response.headers.get('content-length');
|
|
215
|
+
* const reader = response.body.getReader();
|
|
216
|
+
* // ... read stream
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
async getBinary(cid: string): Promise<Response> {
|
|
220
|
+
// Retry logic for content retrieval - content may not be immediately available
|
|
221
|
+
// after upload due to eventual consistency in IPFS Cluster
|
|
222
|
+
// IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
|
|
223
|
+
const maxAttempts = 8;
|
|
224
|
+
let lastError: Error | null = null;
|
|
225
|
+
|
|
226
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
227
|
+
try {
|
|
228
|
+
const response = await this.httpClient.getBinary(
|
|
229
|
+
`/v1/storage/get/${cid}`
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (!response) {
|
|
233
|
+
throw new Error("Response is null");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return response;
|
|
237
|
+
} catch (error: any) {
|
|
238
|
+
lastError = error;
|
|
239
|
+
|
|
240
|
+
// Check if this is a 404 error (content not found)
|
|
241
|
+
const isNotFound =
|
|
242
|
+
error?.httpStatus === 404 ||
|
|
243
|
+
error?.message?.includes("not found") ||
|
|
244
|
+
error?.message?.includes("404");
|
|
245
|
+
|
|
246
|
+
// If it's not a 404 error, or this is the last attempt, give up
|
|
247
|
+
if (!isNotFound || attempt === maxAttempts) {
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
|
|
252
|
+
// This gives up to ~12 seconds total wait time, covering typical pin completion
|
|
253
|
+
const backoffMs = attempt * 2500;
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// This should never be reached, but TypeScript needs it
|
|
259
|
+
throw lastError || new Error("Failed to retrieve content");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Unpin a CID
|
|
264
|
+
*
|
|
265
|
+
* @param cid - Content ID to unpin
|
|
266
|
+
*/
|
|
267
|
+
async unpin(cid: string): Promise<void> {
|
|
268
|
+
await this.httpClient.delete(`/v1/storage/unpin/${cid}`);
|
|
269
|
+
}
|
|
270
|
+
}
|