@debros/network-ts-sdk 0.2.5 → 0.3.2
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 +468 -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 +215 -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);
|
|
@@ -146,8 +135,99 @@ export class HttpClient {
|
|
|
146
135
|
fetchOptions.body = JSON.stringify(options.body);
|
|
147
136
|
}
|
|
148
137
|
|
|
138
|
+
// Extract and log SQL query details for rqlite operations
|
|
139
|
+
const isRqliteOperation = path.includes("/v1/rqlite/");
|
|
140
|
+
let queryDetails: string | null = null;
|
|
141
|
+
if (isRqliteOperation && options.body) {
|
|
142
|
+
try {
|
|
143
|
+
const body =
|
|
144
|
+
typeof options.body === "string"
|
|
145
|
+
? JSON.parse(options.body)
|
|
146
|
+
: options.body;
|
|
147
|
+
|
|
148
|
+
if (body.sql) {
|
|
149
|
+
// Direct SQL query (query/exec endpoints)
|
|
150
|
+
queryDetails = `SQL: ${body.sql}`;
|
|
151
|
+
if (body.args && body.args.length > 0) {
|
|
152
|
+
queryDetails += ` | Args: [${body.args
|
|
153
|
+
.map((a: any) => (typeof a === "string" ? `"${a}"` : a))
|
|
154
|
+
.join(", ")}]`;
|
|
155
|
+
}
|
|
156
|
+
} else if (body.table) {
|
|
157
|
+
// Table-based query (find/find-one/select endpoints)
|
|
158
|
+
queryDetails = `Table: ${body.table}`;
|
|
159
|
+
if (body.criteria && Object.keys(body.criteria).length > 0) {
|
|
160
|
+
queryDetails += ` | Criteria: ${JSON.stringify(body.criteria)}`;
|
|
161
|
+
}
|
|
162
|
+
if (body.options) {
|
|
163
|
+
queryDetails += ` | Options: ${JSON.stringify(body.options)}`;
|
|
164
|
+
}
|
|
165
|
+
if (body.select) {
|
|
166
|
+
queryDetails += ` | Select: ${JSON.stringify(body.select)}`;
|
|
167
|
+
}
|
|
168
|
+
if (body.where) {
|
|
169
|
+
queryDetails += ` | Where: ${JSON.stringify(body.where)}`;
|
|
170
|
+
}
|
|
171
|
+
if (body.limit) {
|
|
172
|
+
queryDetails += ` | Limit: ${body.limit}`;
|
|
173
|
+
}
|
|
174
|
+
if (body.offset) {
|
|
175
|
+
queryDetails += ` | Offset: ${body.offset}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Failed to parse body, ignore
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
149
183
|
try {
|
|
150
|
-
|
|
184
|
+
const result = await this.requestWithRetry(
|
|
185
|
+
url.toString(),
|
|
186
|
+
fetchOptions,
|
|
187
|
+
0,
|
|
188
|
+
startTime
|
|
189
|
+
);
|
|
190
|
+
const duration = performance.now() - startTime;
|
|
191
|
+
if (typeof console !== "undefined") {
|
|
192
|
+
const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(
|
|
193
|
+
2
|
|
194
|
+
)}ms`;
|
|
195
|
+
if (queryDetails) {
|
|
196
|
+
console.log(logMessage);
|
|
197
|
+
console.log(`[HttpClient] ${queryDetails}`);
|
|
198
|
+
} else {
|
|
199
|
+
console.log(logMessage);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const duration = performance.now() - startTime;
|
|
205
|
+
if (typeof console !== "undefined") {
|
|
206
|
+
// For 404 errors on find-one calls, log at warn level (not error) since "not found" is expected
|
|
207
|
+
// Application layer handles these cases in try-catch blocks
|
|
208
|
+
const is404FindOne =
|
|
209
|
+
path === "/v1/rqlite/find-one" &&
|
|
210
|
+
error instanceof SDKError &&
|
|
211
|
+
error.httpStatus === 404;
|
|
212
|
+
|
|
213
|
+
if (is404FindOne) {
|
|
214
|
+
// Log as warning for visibility, but not as error since it's expected behavior
|
|
215
|
+
console.warn(
|
|
216
|
+
`[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(
|
|
217
|
+
2
|
|
218
|
+
)}ms (expected for optional lookups)`
|
|
219
|
+
);
|
|
220
|
+
} else {
|
|
221
|
+
const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(
|
|
222
|
+
2
|
|
223
|
+
)}ms:`;
|
|
224
|
+
console.error(errorMessage, error);
|
|
225
|
+
if (queryDetails) {
|
|
226
|
+
console.error(`[HttpClient] ${queryDetails}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
151
231
|
} finally {
|
|
152
232
|
clearTimeout(timeoutId);
|
|
153
233
|
}
|
|
@@ -156,7 +236,8 @@ export class HttpClient {
|
|
|
156
236
|
private async requestWithRetry(
|
|
157
237
|
url: string,
|
|
158
238
|
options: RequestInit,
|
|
159
|
-
attempt: number = 0
|
|
239
|
+
attempt: number = 0,
|
|
240
|
+
startTime?: number // Track start time for timing across retries
|
|
160
241
|
): Promise<any> {
|
|
161
242
|
try {
|
|
162
243
|
const response = await this.fetch(url, options);
|
|
@@ -185,7 +266,7 @@ export class HttpClient {
|
|
|
185
266
|
await new Promise((resolve) =>
|
|
186
267
|
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
|
|
187
268
|
);
|
|
188
|
-
return this.requestWithRetry(url, options, attempt + 1);
|
|
269
|
+
return this.requestWithRetry(url, options, attempt + 1, startTime);
|
|
189
270
|
}
|
|
190
271
|
throw error;
|
|
191
272
|
}
|
|
@@ -221,6 +302,104 @@ export class HttpClient {
|
|
|
221
302
|
return this.request<T>("DELETE", path, options);
|
|
222
303
|
}
|
|
223
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Upload a file using multipart/form-data
|
|
307
|
+
* This is a special method for file uploads that bypasses JSON serialization
|
|
308
|
+
*/
|
|
309
|
+
async uploadFile<T = any>(
|
|
310
|
+
path: string,
|
|
311
|
+
formData: FormData,
|
|
312
|
+
options?: {
|
|
313
|
+
timeout?: number;
|
|
314
|
+
}
|
|
315
|
+
): Promise<T> {
|
|
316
|
+
const startTime = performance.now(); // Track upload start time
|
|
317
|
+
const url = new URL(this.baseURL + path);
|
|
318
|
+
const headers: Record<string, string> = {
|
|
319
|
+
...this.getAuthHeaders(path),
|
|
320
|
+
// Don't set Content-Type - browser will set it with boundary
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads
|
|
325
|
+
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
|
|
326
|
+
|
|
327
|
+
const fetchOptions: RequestInit = {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers,
|
|
330
|
+
body: formData,
|
|
331
|
+
signal: controller.signal,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const result = await this.requestWithRetry(
|
|
336
|
+
url.toString(),
|
|
337
|
+
fetchOptions,
|
|
338
|
+
0,
|
|
339
|
+
startTime
|
|
340
|
+
);
|
|
341
|
+
const duration = performance.now() - startTime;
|
|
342
|
+
if (typeof console !== "undefined") {
|
|
343
|
+
console.log(
|
|
344
|
+
`[HttpClient] POST ${path} (upload) completed in ${duration.toFixed(
|
|
345
|
+
2
|
|
346
|
+
)}ms`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const duration = performance.now() - startTime;
|
|
352
|
+
if (typeof console !== "undefined") {
|
|
353
|
+
console.error(
|
|
354
|
+
`[HttpClient] POST ${path} (upload) failed after ${duration.toFixed(
|
|
355
|
+
2
|
|
356
|
+
)}ms:`,
|
|
357
|
+
error
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
throw error;
|
|
361
|
+
} finally {
|
|
362
|
+
clearTimeout(timeoutId);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get a binary response (returns Response object for streaming)
|
|
368
|
+
*/
|
|
369
|
+
async getBinary(path: string): Promise<Response> {
|
|
370
|
+
const url = new URL(this.baseURL + path);
|
|
371
|
+
const headers: Record<string, string> = {
|
|
372
|
+
...this.getAuthHeaders(path),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const controller = new AbortController();
|
|
376
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads
|
|
377
|
+
|
|
378
|
+
const fetchOptions: RequestInit = {
|
|
379
|
+
method: "GET",
|
|
380
|
+
headers,
|
|
381
|
+
signal: controller.signal,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const response = await this.fetch(url.toString(), fetchOptions);
|
|
386
|
+
if (!response.ok) {
|
|
387
|
+
clearTimeout(timeoutId);
|
|
388
|
+
const error = await response.json().catch(() => ({
|
|
389
|
+
error: response.statusText,
|
|
390
|
+
}));
|
|
391
|
+
throw SDKError.fromResponse(response.status, error);
|
|
392
|
+
}
|
|
393
|
+
return response;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
clearTimeout(timeoutId);
|
|
396
|
+
if (error instanceof SDKError) {
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
224
403
|
getToken(): string | undefined {
|
|
225
404
|
return this.getAuthToken();
|
|
226
405
|
}
|
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
|
+
}
|