Skip to content

Commit 0219dd7

Browse files
committed
feat: add uploadMultipartFromStream method for multi-part encrypted uploads from a Readable stream
1 parent dc5e27d commit 0219dd7

1 file changed

Lines changed: 144 additions & 1 deletion

File tree

src/services/network/network-facade.service.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@ import {
66
DownloadFileFunction,
77
EncryptFileFunction,
88
UploadFileFunction,
9+
UploadFileMultipartFunction,
910
} from '@internxt/sdk/dist/network';
1011
import { Environment } from '@internxt/inxt-js';
1112
import { randomBytes } from 'node:crypto';
1213
import { Readable, Transform } from 'node:stream';
13-
import { DownloadOptions, UploadOptions, UploadProgressCallback, DownloadProgressCallback } from '../../types/network.types';
14+
import {
15+
DownloadOptions,
16+
UploadOptions,
17+
UploadProgressCallback,
18+
DownloadProgressCallback,
19+
UploadMultipartOptions,
20+
UploadTask,
21+
} from '../../types/network.types';
1422
import { CryptoService } from '../crypto.service';
1523
import { UploadService } from './upload.service';
1624
import { DownloadService } from './download.service';
1725
import { ValidationService } from '../validation.service';
1826
import { HashStream } from '../../utils/hash.utils';
1927
import { RangeOptions } from '../../utils/network.utils';
28+
import { queue, QueueObject } from 'async';
2029

2130
export class NetworkFacade {
2231
private readonly cryptoLib: Network.Crypto;
@@ -183,4 +192,138 @@ export class NetworkFacade {
183192

184193
return [uploadOperation(), abortable];
185194
}
195+
196+
/**
197+
* Performs a multi-part upload encrypting the stream content
198+
*
199+
* @param bucketId The bucket where the file will be uploaded
200+
* @param mnemonic The plain mnemonic of the user
201+
* @param size The total size of the stream content
202+
* @param from The source ReadStream to upload from
203+
* @param options The upload options
204+
* @returns A promise to execute the upload and an abort controller to cancel the upload
205+
*/
206+
async uploadMultipartFromStream(
207+
bucketId: string,
208+
mnemonic: string,
209+
size: number,
210+
from: Readable,
211+
options: UploadMultipartOptions,
212+
): Promise<[Promise<{ fileId: string; hash: Buffer }>, AbortController]> {
213+
const hashStream = new HashStream();
214+
const abortable = options?.abortController ?? new AbortController();
215+
let encryptionTransform: Transform;
216+
let hash: Buffer;
217+
218+
const partsUploadedBytes: Record<number, number> = {};
219+
const fileParts: { PartNumber: number; ETag: string }[] = [];
220+
221+
const onProgress = (partId: number, loadedBytes: number) => {
222+
if (!options?.progressCallback) return;
223+
partsUploadedBytes[partId] = loadedBytes;
224+
const currentTotalLoadedBytes = Object.values(partsUploadedBytes).reduce((a, p) => a + p, 0);
225+
const reportedProgress = Math.round((currentTotalLoadedBytes / size) * 100);
226+
options.progressCallback(reportedProgress);
227+
};
228+
229+
const encryptFile: EncryptFileFunction = async (_, key, iv) => {
230+
const encryptionCipher = this.cryptoService.getEncryptionTransform(
231+
Buffer.from(key as ArrayBuffer),
232+
Buffer.from(iv as ArrayBuffer),
233+
);
234+
const streamInParts = this.cryptoService.encryptStreamInParts(from, encryptionCipher, size, options.parts);
235+
encryptionTransform = streamInParts.pipe(hashStream);
236+
};
237+
238+
const uploadFileMultipart: UploadFileMultipartFunction = async (urls: string[]) => {
239+
let partIndex = 0;
240+
const limitConcurrency = 6;
241+
242+
const worker = async (upload: UploadTask) => {
243+
const { etag } = await this.uploadService.uploadFile(upload.urlToUpload, upload.contentToUpload, {
244+
abortController: abortable,
245+
progressCallback: (loadedBytes: number) => {
246+
onProgress(upload.index, loadedBytes);
247+
},
248+
});
249+
250+
fileParts.push({
251+
ETag: etag,
252+
PartNumber: upload.index + 1,
253+
});
254+
};
255+
256+
const uploadQueue: QueueObject<UploadTask> = queue<UploadTask>(function (task, callback) {
257+
worker(task)
258+
.then(() => {
259+
callback();
260+
})
261+
.catch((e) => {
262+
callback(e);
263+
});
264+
}, limitConcurrency);
265+
266+
for await (const chunk of encryptionTransform) {
267+
const part: Buffer = chunk;
268+
269+
if (uploadQueue.running() === limitConcurrency) {
270+
await uploadQueue.unsaturated();
271+
}
272+
273+
if (abortable.signal.aborted) {
274+
throw new Error('Upload cancelled by user');
275+
}
276+
277+
let errorAlreadyThrown = false;
278+
279+
uploadQueue
280+
.pushAsync({
281+
contentToUpload: part,
282+
urlToUpload: urls[partIndex],
283+
index: partIndex++,
284+
})
285+
.catch((err: Error) => {
286+
if (errorAlreadyThrown) return;
287+
288+
errorAlreadyThrown = true;
289+
if (err) {
290+
uploadQueue.kill();
291+
if (!abortable?.signal.aborted) {
292+
abortable.abort();
293+
}
294+
}
295+
});
296+
}
297+
298+
while (uploadQueue.running() > 0 || uploadQueue.length() > 0) {
299+
await uploadQueue.drain();
300+
}
301+
302+
hash = hashStream.getHash();
303+
return {
304+
hash: hash.toString('hex'),
305+
parts: fileParts.sort((pA, pB) => pA.PartNumber - pB.PartNumber),
306+
};
307+
};
308+
309+
const uploadOperation = async () => {
310+
const uploadResult = await NetworkUpload.uploadMultipartFile(
311+
this.network,
312+
this.cryptoLib,
313+
bucketId,
314+
mnemonic,
315+
size,
316+
encryptFile,
317+
uploadFileMultipart,
318+
options.parts,
319+
);
320+
321+
return {
322+
fileId: uploadResult,
323+
hash: hash,
324+
};
325+
};
326+
327+
return [uploadOperation(), abortable];
328+
}
186329
}

0 commit comments

Comments
 (0)