@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.
- package/README.md +96 -1
- package/dist/index.d.ts +392 -29
- package/dist/index.js +795 -96
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/auth/client.ts +144 -10
- package/src/cache/client.ts +203 -0
- package/src/core/http.ts +248 -13
- package/src/core/ws.ts +87 -80
- package/src/db/repository.ts +6 -2
- package/src/index.ts +44 -10
- package/src/network/client.ts +58 -4
- package/src/pubsub/client.ts +174 -28
- package/src/storage/client.ts +270 -0
package/src/pubsub/client.ts
CHANGED
|
@@ -4,13 +4,64 @@ import { WSClient, WSClientConfig } from "../core/ws";
|
|
|
4
4
|
export interface Message {
|
|
5
5
|
data: string;
|
|
6
6
|
topic: string;
|
|
7
|
-
timestamp
|
|
7
|
+
timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RawEnvelope {
|
|
11
|
+
data: string; // base64-encoded
|
|
12
|
+
timestamp: number;
|
|
13
|
+
topic: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Cross-platform base64 encoding/decoding utilities
|
|
17
|
+
function base64Encode(str: string): string {
|
|
18
|
+
if (typeof Buffer !== "undefined") {
|
|
19
|
+
return Buffer.from(str).toString("base64");
|
|
20
|
+
} else if (typeof btoa !== "undefined") {
|
|
21
|
+
return btoa(
|
|
22
|
+
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
|
|
23
|
+
String.fromCharCode(parseInt(p1, 16))
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
throw new Error("No base64 encoding method available");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function base64EncodeBytes(bytes: Uint8Array): string {
|
|
31
|
+
if (typeof Buffer !== "undefined") {
|
|
32
|
+
return Buffer.from(bytes).toString("base64");
|
|
33
|
+
} else if (typeof btoa !== "undefined") {
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
36
|
+
binary += String.fromCharCode(bytes[i]);
|
|
37
|
+
}
|
|
38
|
+
return btoa(binary);
|
|
39
|
+
}
|
|
40
|
+
throw new Error("No base64 encoding method available");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64Decode(b64: string): string {
|
|
44
|
+
if (typeof Buffer !== "undefined") {
|
|
45
|
+
return Buffer.from(b64, "base64").toString("utf-8");
|
|
46
|
+
} else if (typeof atob !== "undefined") {
|
|
47
|
+
const binary = atob(b64);
|
|
48
|
+
const bytes = new Uint8Array(binary.length);
|
|
49
|
+
for (let i = 0; i < binary.length; i++) {
|
|
50
|
+
bytes[i] = binary.charCodeAt(i);
|
|
51
|
+
}
|
|
52
|
+
return new TextDecoder().decode(bytes);
|
|
53
|
+
}
|
|
54
|
+
throw new Error("No base64 decoding method available");
|
|
8
55
|
}
|
|
9
56
|
|
|
10
57
|
export type MessageHandler = (message: Message) => void;
|
|
11
58
|
export type ErrorHandler = (error: Error) => void;
|
|
12
59
|
export type CloseHandler = () => void;
|
|
13
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Simple PubSub client - one WebSocket connection per topic
|
|
63
|
+
* No connection pooling, no reference counting - keep it simple
|
|
64
|
+
*/
|
|
14
65
|
export class PubSubClient {
|
|
15
66
|
private httpClient: HttpClient;
|
|
16
67
|
private wsConfig: Partial<WSClientConfig>;
|
|
@@ -21,20 +72,30 @@ export class PubSubClient {
|
|
|
21
72
|
}
|
|
22
73
|
|
|
23
74
|
/**
|
|
24
|
-
* Publish a message to a topic
|
|
75
|
+
* Publish a message to a topic via HTTP
|
|
25
76
|
*/
|
|
26
77
|
async publish(topic: string, data: string | Uint8Array): Promise<void> {
|
|
27
|
-
|
|
28
|
-
|
|
78
|
+
let dataBase64: string;
|
|
79
|
+
if (typeof data === "string") {
|
|
80
|
+
dataBase64 = base64Encode(data);
|
|
81
|
+
} else {
|
|
82
|
+
dataBase64 = base64EncodeBytes(data);
|
|
83
|
+
}
|
|
29
84
|
|
|
30
|
-
await this.httpClient.post(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
85
|
+
await this.httpClient.post(
|
|
86
|
+
"/v1/pubsub/publish",
|
|
87
|
+
{
|
|
88
|
+
topic,
|
|
89
|
+
data_base64: dataBase64,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
timeout: 30000,
|
|
93
|
+
}
|
|
94
|
+
);
|
|
34
95
|
}
|
|
35
96
|
|
|
36
97
|
/**
|
|
37
|
-
* List active topics in the current namespace
|
|
98
|
+
* List active topics in the current namespace
|
|
38
99
|
*/
|
|
39
100
|
async topics(): Promise<string[]> {
|
|
40
101
|
const response = await this.httpClient.get<{ topics: string[] }>(
|
|
@@ -44,8 +105,8 @@ export class PubSubClient {
|
|
|
44
105
|
}
|
|
45
106
|
|
|
46
107
|
/**
|
|
47
|
-
* Subscribe to a topic via WebSocket
|
|
48
|
-
*
|
|
108
|
+
* Subscribe to a topic via WebSocket
|
|
109
|
+
* Creates one WebSocket connection per topic
|
|
49
110
|
*/
|
|
50
111
|
async subscribe(
|
|
51
112
|
topic: string,
|
|
@@ -55,16 +116,23 @@ export class PubSubClient {
|
|
|
55
116
|
onClose?: CloseHandler;
|
|
56
117
|
} = {}
|
|
57
118
|
): Promise<Subscription> {
|
|
58
|
-
|
|
119
|
+
// Build WebSocket URL for this topic
|
|
120
|
+
const wsUrl = new URL(this.wsConfig.wsURL || "ws://127.0.0.1:6001");
|
|
59
121
|
wsUrl.pathname = "/v1/pubsub/ws";
|
|
60
122
|
wsUrl.searchParams.set("topic", topic);
|
|
61
123
|
|
|
124
|
+
const authToken = this.httpClient.getApiKey() ?? this.httpClient.getToken();
|
|
125
|
+
|
|
126
|
+
// Create WebSocket client
|
|
62
127
|
const wsClient = new WSClient({
|
|
63
128
|
...this.wsConfig,
|
|
64
129
|
wsURL: wsUrl.toString(),
|
|
65
|
-
authToken
|
|
130
|
+
authToken,
|
|
66
131
|
});
|
|
67
132
|
|
|
133
|
+
await wsClient.connect();
|
|
134
|
+
|
|
135
|
+
// Create subscription wrapper
|
|
68
136
|
const subscription = new Subscription(wsClient, topic);
|
|
69
137
|
|
|
70
138
|
if (handlers.onMessage) {
|
|
@@ -77,66 +145,144 @@ export class PubSubClient {
|
|
|
77
145
|
subscription.onClose(handlers.onClose);
|
|
78
146
|
}
|
|
79
147
|
|
|
80
|
-
await wsClient.connect();
|
|
81
148
|
return subscription;
|
|
82
149
|
}
|
|
83
150
|
}
|
|
84
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Subscription represents an active WebSocket subscription to a topic
|
|
154
|
+
*/
|
|
85
155
|
export class Subscription {
|
|
86
156
|
private wsClient: WSClient;
|
|
87
157
|
private topic: string;
|
|
88
158
|
private messageHandlers: Set<MessageHandler> = new Set();
|
|
89
159
|
private errorHandlers: Set<ErrorHandler> = new Set();
|
|
90
160
|
private closeHandlers: Set<CloseHandler> = new Set();
|
|
161
|
+
private isClosed = false;
|
|
162
|
+
private wsMessageHandler: ((data: string) => void) | null = null;
|
|
163
|
+
private wsErrorHandler: ((error: Error) => void) | null = null;
|
|
164
|
+
private wsCloseHandler: (() => void) | null = null;
|
|
91
165
|
|
|
92
166
|
constructor(wsClient: WSClient, topic: string) {
|
|
93
167
|
this.wsClient = wsClient;
|
|
94
168
|
this.topic = topic;
|
|
95
169
|
|
|
96
|
-
|
|
170
|
+
// Register message handler
|
|
171
|
+
this.wsMessageHandler = (data) => {
|
|
97
172
|
try {
|
|
173
|
+
// Parse gateway JSON envelope: {data: base64String, timestamp, topic}
|
|
174
|
+
const envelope: RawEnvelope = JSON.parse(data);
|
|
175
|
+
|
|
176
|
+
// Validate envelope structure
|
|
177
|
+
if (!envelope || typeof envelope !== "object") {
|
|
178
|
+
throw new Error("Invalid envelope: not an object");
|
|
179
|
+
}
|
|
180
|
+
if (!envelope.data || typeof envelope.data !== "string") {
|
|
181
|
+
throw new Error("Invalid envelope: missing or invalid data field");
|
|
182
|
+
}
|
|
183
|
+
if (!envelope.topic || typeof envelope.topic !== "string") {
|
|
184
|
+
throw new Error("Invalid envelope: missing or invalid topic field");
|
|
185
|
+
}
|
|
186
|
+
if (typeof envelope.timestamp !== "number") {
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Invalid envelope: missing or invalid timestamp field"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Decode base64 data
|
|
193
|
+
const messageData = base64Decode(envelope.data);
|
|
194
|
+
|
|
98
195
|
const message: Message = {
|
|
99
|
-
topic:
|
|
100
|
-
data:
|
|
101
|
-
timestamp:
|
|
196
|
+
topic: envelope.topic,
|
|
197
|
+
data: messageData,
|
|
198
|
+
timestamp: envelope.timestamp,
|
|
102
199
|
};
|
|
200
|
+
|
|
201
|
+
console.log("[Subscription] Received message on topic:", this.topic);
|
|
103
202
|
this.messageHandlers.forEach((handler) => handler(message));
|
|
104
203
|
} catch (error) {
|
|
204
|
+
console.error("[Subscription] Error processing message:", error);
|
|
105
205
|
this.errorHandlers.forEach((handler) =>
|
|
106
206
|
handler(error instanceof Error ? error : new Error(String(error)))
|
|
107
207
|
);
|
|
108
208
|
}
|
|
109
|
-
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
this.wsClient.onMessage(this.wsMessageHandler);
|
|
110
212
|
|
|
111
|
-
|
|
213
|
+
// Register error handler
|
|
214
|
+
this.wsErrorHandler = (error) => {
|
|
112
215
|
this.errorHandlers.forEach((handler) => handler(error));
|
|
113
|
-
}
|
|
216
|
+
};
|
|
217
|
+
this.wsClient.onError(this.wsErrorHandler);
|
|
114
218
|
|
|
115
|
-
|
|
219
|
+
// Register close handler
|
|
220
|
+
this.wsCloseHandler = () => {
|
|
116
221
|
this.closeHandlers.forEach((handler) => handler());
|
|
117
|
-
}
|
|
222
|
+
};
|
|
223
|
+
this.wsClient.onClose(this.wsCloseHandler);
|
|
118
224
|
}
|
|
119
225
|
|
|
120
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Register message handler
|
|
228
|
+
*/
|
|
229
|
+
onMessage(handler: MessageHandler): () => void {
|
|
121
230
|
this.messageHandlers.add(handler);
|
|
122
231
|
return () => this.messageHandlers.delete(handler);
|
|
123
232
|
}
|
|
124
233
|
|
|
125
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Register error handler
|
|
236
|
+
*/
|
|
237
|
+
onError(handler: ErrorHandler): () => void {
|
|
126
238
|
this.errorHandlers.add(handler);
|
|
127
239
|
return () => this.errorHandlers.delete(handler);
|
|
128
240
|
}
|
|
129
241
|
|
|
130
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Register close handler
|
|
244
|
+
*/
|
|
245
|
+
onClose(handler: CloseHandler): () => void {
|
|
131
246
|
this.closeHandlers.add(handler);
|
|
132
247
|
return () => this.closeHandlers.delete(handler);
|
|
133
248
|
}
|
|
134
249
|
|
|
135
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Close subscription and underlying WebSocket
|
|
252
|
+
*/
|
|
253
|
+
close(): void {
|
|
254
|
+
if (this.isClosed) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.isClosed = true;
|
|
258
|
+
|
|
259
|
+
// Remove handlers from WSClient
|
|
260
|
+
if (this.wsMessageHandler) {
|
|
261
|
+
this.wsClient.offMessage(this.wsMessageHandler);
|
|
262
|
+
this.wsMessageHandler = null;
|
|
263
|
+
}
|
|
264
|
+
if (this.wsErrorHandler) {
|
|
265
|
+
this.wsClient.offError(this.wsErrorHandler);
|
|
266
|
+
this.wsErrorHandler = null;
|
|
267
|
+
}
|
|
268
|
+
if (this.wsCloseHandler) {
|
|
269
|
+
this.wsClient.offClose(this.wsCloseHandler);
|
|
270
|
+
this.wsCloseHandler = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Clear all local handlers
|
|
274
|
+
this.messageHandlers.clear();
|
|
275
|
+
this.errorHandlers.clear();
|
|
276
|
+
this.closeHandlers.clear();
|
|
277
|
+
|
|
278
|
+
// Close WebSocket connection
|
|
136
279
|
this.wsClient.close();
|
|
137
280
|
}
|
|
138
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Check if subscription is active
|
|
284
|
+
*/
|
|
139
285
|
isConnected(): boolean {
|
|
140
|
-
return this.wsClient.isConnected();
|
|
286
|
+
return !this.isClosed && this.wsClient.isConnected();
|
|
141
287
|
}
|
|
142
288
|
}
|
|
@@ -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
|
+
}
|