@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.
@@ -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?: number;
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
- const dataBase64 =
28
- typeof data === "string" ? Buffer.from(data).toString("base64") : Buffer.from(data).toString("base64");
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("/v1/pubsub/publish", {
31
- topic,
32
- data_base64: dataBase64,
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
- * Returns a subscription object with event handlers.
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
- const wsUrl = new URL(this.wsConfig.wsURL || "ws://localhost:6001");
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: this.httpClient.getToken(),
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
- this.wsClient.onMessage((data) => {
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: this.topic,
100
- data: data,
101
- timestamp: Date.now(),
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
- this.wsClient.onError((error) => {
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
- this.wsClient.onClose(() => {
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
- onMessage(handler: MessageHandler) {
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
- onError(handler: ErrorHandler) {
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
- onClose(handler: CloseHandler) {
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
- close() {
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
+ }