@zbruceli/openclaw-dchat 0.1.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import http from "http";
3
+ import { EventEmitter } from "events";
4
+ import { IpfsService, IPFS_FILE_TYPE, mimeToIpfsFileType, buildFileMetadata } from "./ipfs.js";
5
+ import { decryptAesGcm, encryptAesGcm, keyToByteArray, byteArrayToKey } from "./crypto.js";
6
+
7
+ describe("mimeToIpfsFileType", () => {
8
+ it("maps image/* to IMAGE (1)", () => {
9
+ expect(mimeToIpfsFileType("image/png")).toBe(IPFS_FILE_TYPE.IMAGE);
10
+ expect(mimeToIpfsFileType("image/jpeg")).toBe(IPFS_FILE_TYPE.IMAGE);
11
+ expect(mimeToIpfsFileType("image/gif")).toBe(IPFS_FILE_TYPE.IMAGE);
12
+ expect(mimeToIpfsFileType("Image/WebP")).toBe(IPFS_FILE_TYPE.IMAGE);
13
+ });
14
+
15
+ it("maps audio/* to AUDIO (2)", () => {
16
+ expect(mimeToIpfsFileType("audio/mpeg")).toBe(IPFS_FILE_TYPE.AUDIO);
17
+ expect(mimeToIpfsFileType("audio/ogg")).toBe(IPFS_FILE_TYPE.AUDIO);
18
+ expect(mimeToIpfsFileType("Audio/WAV")).toBe(IPFS_FILE_TYPE.AUDIO);
19
+ });
20
+
21
+ it("maps video/* to VIDEO (3)", () => {
22
+ expect(mimeToIpfsFileType("video/mp4")).toBe(IPFS_FILE_TYPE.VIDEO);
23
+ expect(mimeToIpfsFileType("Video/WebM")).toBe(IPFS_FILE_TYPE.VIDEO);
24
+ });
25
+
26
+ it("maps unknown types to FILE (0)", () => {
27
+ expect(mimeToIpfsFileType("application/pdf")).toBe(IPFS_FILE_TYPE.FILE);
28
+ expect(mimeToIpfsFileType("text/plain")).toBe(IPFS_FILE_TYPE.FILE);
29
+ });
30
+
31
+ it("defaults to IMAGE when undefined", () => {
32
+ expect(mimeToIpfsFileType(undefined)).toBe(IPFS_FILE_TYPE.IMAGE);
33
+ });
34
+ });
35
+
36
+ describe("IPFS_FILE_TYPE constants", () => {
37
+ it("has correct values", () => {
38
+ expect(IPFS_FILE_TYPE.FILE).toBe(0);
39
+ expect(IPFS_FILE_TYPE.IMAGE).toBe(1);
40
+ expect(IPFS_FILE_TYPE.AUDIO).toBe(2);
41
+ expect(IPFS_FILE_TYPE.VIDEO).toBe(3);
42
+ });
43
+ });
44
+
45
+ describe("IpfsService.buildMessageOptions", () => {
46
+ const service = new IpfsService("10.0.0.1:5001");
47
+
48
+ const fakeResult = {
49
+ hash: "QmTestHash123",
50
+ key: Buffer.alloc(16, 0xab),
51
+ nonce: Buffer.alloc(12, 0xcd),
52
+ nonceSize: 12,
53
+ };
54
+
55
+ it("produces correct wire format for IMAGE", () => {
56
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.IMAGE);
57
+
58
+ expect(opts.ipfsHash).toBe("QmTestHash123");
59
+ expect(opts.ipfsIp).toBe("10.0.0.1:5001");
60
+ expect(opts.ipfsEncrypt).toBe(1);
61
+ expect(opts.ipfsEncryptAlgorithm).toBe("AES/GCM/NoPadding");
62
+ expect(opts.ipfsEncryptKeyBytes).toEqual(keyToByteArray(fakeResult.key));
63
+ expect(opts.ipfsEncryptNonceSize).toBe(12);
64
+ expect(opts.fileType).toBe(IPFS_FILE_TYPE.IMAGE);
65
+ });
66
+
67
+ it("produces correct wire format for AUDIO with duration", () => {
68
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.AUDIO, {
69
+ mediaDuration: 5.3,
70
+ fileMimeType: "audio/ogg",
71
+ });
72
+
73
+ expect(opts.fileType).toBe(IPFS_FILE_TYPE.AUDIO);
74
+ expect(opts.mediaDuration).toBe(5.3);
75
+ expect(opts.fileMimeType).toBe("audio/ogg");
76
+ });
77
+
78
+ it("produces correct wire format for FILE with name/ext", () => {
79
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.FILE, {
80
+ fileName: "doc.pdf",
81
+ fileExt: ".pdf",
82
+ fileMimeType: "application/pdf",
83
+ fileSize: 12345,
84
+ });
85
+
86
+ expect(opts.fileType).toBe(IPFS_FILE_TYPE.FILE);
87
+ expect(opts.fileName).toBe("doc.pdf");
88
+ expect(opts.fileExt).toBe(".pdf");
89
+ expect(opts.fileMimeType).toBe("application/pdf");
90
+ expect(opts.fileSize).toBe(12345);
91
+ });
92
+
93
+ it("omits optional extra fields when not provided", () => {
94
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.IMAGE);
95
+
96
+ expect(opts.fileMimeType).toBeUndefined();
97
+ expect(opts.fileName).toBeUndefined();
98
+ expect(opts.fileExt).toBeUndefined();
99
+ expect(opts.fileSize).toBeUndefined();
100
+ expect(opts.mediaWidth).toBeUndefined();
101
+ expect(opts.mediaHeight).toBeUndefined();
102
+ expect(opts.mediaDuration).toBeUndefined();
103
+ });
104
+
105
+ it("includes image dimensions when provided", () => {
106
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.IMAGE, {
107
+ mediaWidth: 1920,
108
+ mediaHeight: 1080,
109
+ });
110
+
111
+ expect(opts.mediaWidth).toBe(1920);
112
+ expect(opts.mediaHeight).toBe(1080);
113
+ });
114
+ });
115
+
116
+ describe("IpfsService constructor", () => {
117
+ it("parses host and port from gateway string", () => {
118
+ const service = new IpfsService("192.168.1.1:8080");
119
+ const opts = service.buildMessageOptions(
120
+ { hash: "Qm", key: Buffer.alloc(16), nonce: Buffer.alloc(12), nonceSize: 12 },
121
+ IPFS_FILE_TYPE.IMAGE,
122
+ );
123
+ expect(opts.ipfsIp).toBe("192.168.1.1:8080");
124
+ });
125
+
126
+ it("uses default gateway when none provided", () => {
127
+ const service = new IpfsService();
128
+ const opts = service.buildMessageOptions(
129
+ { hash: "Qm", key: Buffer.alloc(16), nonce: Buffer.alloc(12), nonceSize: 12 },
130
+ IPFS_FILE_TYPE.IMAGE,
131
+ );
132
+ expect(opts.ipfsIp).toBe("64.225.88.71:80");
133
+ });
134
+ });
135
+
136
+ describe("encrypt → upload → download → decrypt round-trip", () => {
137
+ it("data survives round-trip through encryption and key serialization", () => {
138
+ // Simulate the full data flow without a real IPFS server:
139
+ // 1. Encrypt (what upload does internally)
140
+ const original = Buffer.from("Hello IPFS image data!");
141
+ const { ciphertext, key, nonce } = encryptAesGcm(original);
142
+
143
+ // 2. Serialize key to wire format (what buildMessageOptions does)
144
+ const wireKeyBytes = keyToByteArray(key);
145
+ const nonceSize = nonce.length;
146
+
147
+ // 3. Deserialize and decrypt (what download does)
148
+ const restoredKey = byteArrayToKey(wireKeyBytes);
149
+ const decrypted = decryptAesGcm(ciphertext, restoredKey, nonceSize);
150
+
151
+ expect(decrypted.toString()).toBe(original.toString());
152
+ });
153
+ });
154
+
155
+ describe("IpfsService HTTP error handling", () => {
156
+ beforeEach(() => {
157
+ vi.restoreAllMocks();
158
+ });
159
+
160
+ it("rejects on non-200 response", async () => {
161
+ // Mock http.request to return a 500 response
162
+ const mockRes = new EventEmitter() as any;
163
+ mockRes.statusCode = 500;
164
+
165
+ const mockReq = new EventEmitter() as any;
166
+ mockReq.write = vi.fn();
167
+ mockReq.end = vi.fn();
168
+ mockReq.destroy = vi.fn();
169
+
170
+ vi.spyOn(http, "request").mockImplementation((_opts: any, callback: any) => {
171
+ process.nextTick(() => {
172
+ callback(mockRes);
173
+ mockRes.emit("data", Buffer.from("Internal Server Error"));
174
+ mockRes.emit("end");
175
+ });
176
+ return mockReq;
177
+ });
178
+
179
+ const service = new IpfsService("127.0.0.1:5001");
180
+ await expect(service.upload(Buffer.from("test"))).rejects.toThrow("IPFS HTTP 500");
181
+ });
182
+
183
+ it("rejects on request timeout", async () => {
184
+ const mockReq = new EventEmitter() as any;
185
+ mockReq.write = vi.fn();
186
+ mockReq.end = vi.fn();
187
+ mockReq.destroy = vi.fn();
188
+
189
+ vi.spyOn(http, "request").mockImplementation(() => {
190
+ process.nextTick(() => {
191
+ mockReq.emit("timeout");
192
+ });
193
+ return mockReq;
194
+ });
195
+
196
+ const service = new IpfsService("127.0.0.1:5001");
197
+ await expect(service.upload(Buffer.from("test"))).rejects.toThrow("IPFS request timed out");
198
+ });
199
+
200
+ it("rejects on connection error", async () => {
201
+ const mockReq = new EventEmitter() as any;
202
+ mockReq.write = vi.fn();
203
+ mockReq.end = vi.fn();
204
+ mockReq.destroy = vi.fn();
205
+
206
+ vi.spyOn(http, "request").mockImplementation(() => {
207
+ process.nextTick(() => {
208
+ mockReq.emit("error", new Error("ECONNREFUSED"));
209
+ });
210
+ return mockReq;
211
+ });
212
+
213
+ const service = new IpfsService("127.0.0.1:5001");
214
+ await expect(service.upload(Buffer.from("test"))).rejects.toThrow("ECONNREFUSED");
215
+ });
216
+
217
+ it("download rejects on non-200 response", async () => {
218
+ const mockRes = new EventEmitter() as any;
219
+ mockRes.statusCode = 404;
220
+
221
+ const mockReq = new EventEmitter() as any;
222
+ mockReq.write = vi.fn();
223
+ mockReq.end = vi.fn();
224
+ mockReq.destroy = vi.fn();
225
+
226
+ vi.spyOn(http, "request").mockImplementation((_opts: any, callback: any) => {
227
+ process.nextTick(() => {
228
+ callback(mockRes);
229
+ mockRes.emit("data", Buffer.from("not found"));
230
+ mockRes.emit("end");
231
+ });
232
+ return mockReq;
233
+ });
234
+
235
+ const service = new IpfsService("127.0.0.1:5001");
236
+ await expect(
237
+ service.download("QmMissing", { encrypt: 1, encryptKeyBytes: Array.from(Buffer.alloc(16)), encryptNonceSize: 12 }),
238
+ ).rejects.toThrow("IPFS HTTP 404");
239
+ });
240
+ });
241
+
242
+ describe("buildFileMetadata", () => {
243
+ it("extracts extension from fileName", () => {
244
+ const result = buildFileMetadata({
245
+ buffer: Buffer.from("pdf content"),
246
+ contentType: "application/pdf",
247
+ fileName: "report.pdf",
248
+ });
249
+ expect(result.fileName).toBe("report.pdf");
250
+ expect(result.fileExt).toBe("pdf");
251
+ expect(result.fileSize).toBe(11);
252
+ });
253
+
254
+ it("derives extension from MIME when fileName has no extension", () => {
255
+ const result = buildFileMetadata({
256
+ buffer: Buffer.from("data"),
257
+ contentType: "application/pdf",
258
+ fileName: "report",
259
+ });
260
+ expect(result.fileName).toBe("report.pdf");
261
+ expect(result.fileExt).toBe("pdf");
262
+ });
263
+
264
+ it("derives extension from MIME when no fileName", () => {
265
+ const result = buildFileMetadata({
266
+ buffer: Buffer.from("data"),
267
+ contentType: "image/png",
268
+ });
269
+ expect(result.fileName).toBe("file.png");
270
+ expect(result.fileExt).toBe("png");
271
+ });
272
+
273
+ it("falls back to bin for unknown MIME and no fileName", () => {
274
+ const result = buildFileMetadata({
275
+ buffer: Buffer.from("data"),
276
+ contentType: "application/x-unknown",
277
+ });
278
+ expect(result.fileName).toBe("file.bin");
279
+ expect(result.fileExt).toBe("bin");
280
+ });
281
+
282
+ it("handles docx MIME type", () => {
283
+ const result = buildFileMetadata({
284
+ buffer: Buffer.from("docx"),
285
+ contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
286
+ fileName: "letter.docx",
287
+ });
288
+ expect(result.fileExt).toBe("docx");
289
+ expect(result.fileName).toBe("letter.docx");
290
+ });
291
+
292
+ it("prefers fileName extension over MIME", () => {
293
+ const result = buildFileMetadata({
294
+ buffer: Buffer.from("data"),
295
+ contentType: "application/octet-stream",
296
+ fileName: "archive.tar.gz",
297
+ });
298
+ expect(result.fileExt).toBe("gz");
299
+ expect(result.fileName).toBe("archive.tar.gz");
300
+ });
301
+ });
package/src/ipfs.ts ADDED
@@ -0,0 +1,268 @@
1
+ import http from "http";
2
+ import https from "https";
3
+ import { encryptAesGcm, decryptAesGcm, keyToByteArray, byteArrayToKey } from "./crypto.js";
4
+ import type { MessageOptions } from "./types.js";
5
+
6
+ /** IPFS file type constants matching nMobile wire format. */
7
+ export const IPFS_FILE_TYPE = {
8
+ FILE: 0,
9
+ IMAGE: 1,
10
+ AUDIO: 2,
11
+ VIDEO: 3,
12
+ } as const;
13
+
14
+ /** Map a MIME type string to an nMobile IPFS file type number. */
15
+ export function mimeToIpfsFileType(mime?: string): number {
16
+ if (!mime) return IPFS_FILE_TYPE.IMAGE; // default to image
17
+ const lower = mime.toLowerCase();
18
+ if (lower.startsWith("image/")) return IPFS_FILE_TYPE.IMAGE;
19
+ if (lower.startsWith("audio/")) return IPFS_FILE_TYPE.AUDIO;
20
+ if (lower.startsWith("video/")) return IPFS_FILE_TYPE.VIDEO;
21
+ return IPFS_FILE_TYPE.FILE;
22
+ }
23
+
24
+ /** Common MIME → extension mappings for file transfers. */
25
+ const MIME_TO_EXT: Record<string, string> = {
26
+ "application/pdf": "pdf",
27
+ "application/zip": "zip",
28
+ "application/x-zip-compressed": "zip",
29
+ "application/msword": "doc",
30
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
31
+ "application/vnd.ms-excel": "xls",
32
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
33
+ "application/vnd.ms-powerpoint": "ppt",
34
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
35
+ "text/plain": "txt",
36
+ "text/csv": "csv",
37
+ "text/html": "html",
38
+ "application/json": "json",
39
+ "application/xml": "xml",
40
+ "image/png": "png",
41
+ "image/jpeg": "jpg",
42
+ "image/gif": "gif",
43
+ "image/webp": "webp",
44
+ "audio/aac": "aac",
45
+ "audio/mpeg": "mp3",
46
+ "audio/ogg": "ogg",
47
+ "audio/wav": "wav",
48
+ "video/mp4": "mp4",
49
+ "video/webm": "webm",
50
+ };
51
+
52
+ /**
53
+ * Build file metadata (fileName, fileExt, fileSize) for outbound media.
54
+ * Derives extension from the original filename or MIME type.
55
+ */
56
+ export function buildFileMetadata(media: {
57
+ buffer: Buffer;
58
+ contentType?: string;
59
+ fileName?: string;
60
+ }): { fileName: string; fileExt: string; fileSize: number } {
61
+ let ext: string | undefined;
62
+ let name = media.fileName;
63
+
64
+ // Try to get extension from filename
65
+ if (name) {
66
+ const dotIdx = name.lastIndexOf(".");
67
+ if (dotIdx > 0) {
68
+ ext = name.substring(dotIdx + 1).toLowerCase();
69
+ }
70
+ }
71
+
72
+ // Fall back to MIME type
73
+ if (!ext && media.contentType) {
74
+ ext = MIME_TO_EXT[media.contentType.toLowerCase()];
75
+ }
76
+
77
+ ext = ext || "bin";
78
+
79
+ // Ensure filename has extension
80
+ if (!name) {
81
+ name = `file.${ext}`;
82
+ } else if (!name.includes(".")) {
83
+ name = `${name}.${ext}`;
84
+ }
85
+
86
+ return { fileName: name, fileExt: ext, fileSize: media.buffer.length };
87
+ }
88
+
89
+ export interface IpfsUploadResult {
90
+ hash: string;
91
+ key: Buffer;
92
+ nonce: Buffer;
93
+ nonceSize: number;
94
+ }
95
+
96
+ const DEFAULT_TIMEOUT = 60_000;
97
+
98
+ /**
99
+ * IPFS service for uploading/downloading encrypted media
100
+ * via an IPFS HTTP API gateway (compatible with nMobile).
101
+ */
102
+ export class IpfsService {
103
+ private host: string;
104
+ private port: number;
105
+ private protocol: "http" | "https";
106
+
107
+ constructor(gateway?: string) {
108
+ const gw = gateway ?? "64.225.88.71:80";
109
+ const parts = gw.split(":");
110
+ this.host = parts[0];
111
+ this.port = parseInt(parts[1] ?? "80", 10);
112
+ this.protocol = this.port === 443 ? "https" : "http";
113
+ }
114
+
115
+ /**
116
+ * Encrypt plaintext with AES-128-GCM, then upload to IPFS via /api/v0/add.
117
+ */
118
+ async upload(plaintext: Buffer): Promise<IpfsUploadResult> {
119
+ const { ciphertext, key, nonce } = encryptAesGcm(plaintext);
120
+
121
+ const boundary = "----IpfsBoundary" + Date.now().toString(36);
122
+ const fieldName = "file";
123
+ const fileName = "upload";
124
+
125
+ // Build multipart form-data manually
126
+ const header = Buffer.from(
127
+ `--${boundary}\r\n` +
128
+ `Content-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\n` +
129
+ `Content-Type: application/octet-stream\r\n\r\n`,
130
+ );
131
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
132
+ const body = Buffer.concat([header, ciphertext, footer]);
133
+
134
+ const responseBody = await this._request("POST", "/api/v0/add", body, {
135
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
136
+ });
137
+
138
+ const result = JSON.parse(responseBody);
139
+ const hash = result.Hash;
140
+ if (!hash) {
141
+ throw new Error(`IPFS add response missing Hash: ${responseBody}`);
142
+ }
143
+
144
+ return { hash, key, nonce, nonceSize: nonce.length };
145
+ }
146
+
147
+ /**
148
+ * Download from IPFS via /api/v0/cat, then decrypt with AES-128-GCM.
149
+ */
150
+ async download(
151
+ hash: string,
152
+ encryptOpts: {
153
+ encrypt?: number;
154
+ encryptKeyBytes?: number[];
155
+ encryptNonceSize?: number;
156
+ },
157
+ ): Promise<Buffer> {
158
+ const responseBody = await this._requestRaw("POST", `/api/v0/cat?arg=${hash}`);
159
+
160
+ if (!encryptOpts.encrypt || !encryptOpts.encryptKeyBytes) {
161
+ // Not encrypted — return raw
162
+ return responseBody;
163
+ }
164
+
165
+ const key = byteArrayToKey(encryptOpts.encryptKeyBytes);
166
+ const nonceSize = encryptOpts.encryptNonceSize ?? 12;
167
+ return decryptAesGcm(responseBody, key, nonceSize);
168
+ }
169
+
170
+ /**
171
+ * Build nMobile-compatible MessageOptions from an upload result.
172
+ */
173
+ buildMessageOptions(
174
+ uploadResult: IpfsUploadResult,
175
+ fileType: number,
176
+ extra?: {
177
+ fileMimeType?: string;
178
+ fileName?: string;
179
+ fileExt?: string;
180
+ fileSize?: number;
181
+ mediaWidth?: number;
182
+ mediaHeight?: number;
183
+ mediaDuration?: number;
184
+ },
185
+ ): MessageOptions {
186
+ const options: MessageOptions = {
187
+ ipfsHash: uploadResult.hash,
188
+ ipfsIp: `${this.host}:${this.port}`,
189
+ ipfsEncrypt: 1,
190
+ ipfsEncryptAlgorithm: "AES/GCM/NoPadding",
191
+ ipfsEncryptKeyBytes: keyToByteArray(uploadResult.key),
192
+ ipfsEncryptNonceSize: uploadResult.nonceSize,
193
+ fileType,
194
+ };
195
+
196
+ if (extra?.fileMimeType) options.fileMimeType = extra.fileMimeType;
197
+ if (extra?.fileName) options.fileName = extra.fileName;
198
+ if (extra?.fileExt) options.fileExt = extra.fileExt;
199
+ if (extra?.fileSize !== undefined) options.fileSize = extra.fileSize;
200
+ if (extra?.mediaWidth !== undefined) options.mediaWidth = extra.mediaWidth;
201
+ if (extra?.mediaHeight !== undefined) options.mediaHeight = extra.mediaHeight;
202
+ if (extra?.mediaDuration !== undefined) options.mediaDuration = extra.mediaDuration;
203
+
204
+ return options;
205
+ }
206
+
207
+ /** HTTP request returning text response. */
208
+ private _request(
209
+ method: string,
210
+ path: string,
211
+ body?: Buffer,
212
+ headers?: Record<string, string>,
213
+ ): Promise<string> {
214
+ return this._requestRaw(method, path, body, headers).then((buf) => buf.toString("utf-8"));
215
+ }
216
+
217
+ /** HTTP request returning raw Buffer response. */
218
+ private _requestRaw(
219
+ method: string,
220
+ path: string,
221
+ body?: Buffer,
222
+ headers?: Record<string, string>,
223
+ ): Promise<Buffer> {
224
+ const transport = this.protocol === "https" ? https : http;
225
+
226
+ return new Promise<Buffer>((resolve, reject) => {
227
+ const req = transport.request(
228
+ {
229
+ hostname: this.host,
230
+ port: this.port,
231
+ path,
232
+ method,
233
+ headers: {
234
+ ...headers,
235
+ ...(body ? { "Content-Length": body.length.toString() } : {}),
236
+ },
237
+ timeout: DEFAULT_TIMEOUT,
238
+ },
239
+ (res) => {
240
+ const chunks: Buffer[] = [];
241
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
242
+ res.on("end", () => {
243
+ const result = Buffer.concat(chunks);
244
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
245
+ reject(
246
+ new Error(
247
+ `IPFS HTTP ${res.statusCode}: ${result.toString("utf-8").slice(0, 200)}`,
248
+ ),
249
+ );
250
+ return;
251
+ }
252
+ resolve(result);
253
+ });
254
+ res.on("error", reject);
255
+ },
256
+ );
257
+
258
+ req.on("timeout", () => {
259
+ req.destroy();
260
+ reject(new Error("IPFS request timed out"));
261
+ });
262
+ req.on("error", reject);
263
+
264
+ if (body) req.write(body);
265
+ req.end();
266
+ });
267
+ }
268
+ }