Skip to content

Commit 1937592

Browse files
authored
Merge pull request #165 from internxt/feat/pb-3405-add-multipart-upload
[PB-3405]: feat/add-multipart-upload
2 parents 726b377 + 8d8575a commit 1937592

12 files changed

Lines changed: 456 additions & 31 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@internxt/sdk": "1.7.0",
4444
"@oclif/core": "4.2.3",
4545
"@types/validator": "13.12.2",
46+
"async": "3.2.6",
4647
"axios": "1.7.9",
4748
"bip39": "3.1.0",
4849
"body-parser": "1.20.3",
@@ -70,6 +71,7 @@
7071
"@internxt/prettier-config": "internxt/prettier-config#v1.0.2",
7172
"@oclif/test": "4.1.7",
7273
"@openpgp/web-stream-tools": "0.0.11-patch-1",
74+
"@types/async": "3.2.24",
7375
"@types/cli-progress": "3.11.6",
7476
"@types/express": "5.0.0",
7577
"@types/mime-types": "2.1.4",

src/commands/upload-file.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,38 @@ export default class UploadFile extends Command {
8282
linewrap: true,
8383
});
8484
progressBar.start(100, 0);
85-
const [uploadPromise, abortable] = await networkFacade.uploadFromStream(
86-
user.bucket,
87-
user.mnemonic,
88-
stats.size,
89-
fileStream,
90-
{
85+
86+
const minimumMultipartThreshold = 100 * 1024 * 1024;
87+
const useMultipart = stats.size > minimumMultipartThreshold;
88+
const partSize = 30 * 1024 * 1024;
89+
const parts = Math.ceil(stats.size / partSize);
90+
91+
let uploadOperation: Promise<
92+
[
93+
Promise<{
94+
fileId: string;
95+
hash: Buffer;
96+
}>,
97+
AbortController,
98+
]
99+
>;
100+
101+
if (useMultipart) {
102+
uploadOperation = networkFacade.uploadMultipartFromStream(user.bucket, user.mnemonic, stats.size, fileStream, {
103+
parts,
91104
progressCallback: (progress) => {
92105
progressBar.update(progress * 0.99);
93106
},
94-
},
95-
);
107+
});
108+
} else {
109+
uploadOperation = networkFacade.uploadFromStream(user.bucket, user.mnemonic, stats.size, fileStream, {
110+
progressCallback: (progress) => {
111+
progressBar.update(progress * 0.99);
112+
},
113+
});
114+
}
115+
116+
const [uploadPromise, abortable] = await uploadOperation;
96117

97118
process.on('SIGINT', () => {
98119
abortable.abort('SIGINT received');

src/services/crypto.service.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CryptoProvider } from '@internxt/sdk';
22
import { Keys, Password } from '@internxt/sdk/dist/auth';
33
import { createCipheriv, createDecipheriv, createHash, Decipher, pbkdf2Sync, randomBytes } from 'node:crypto';
4-
import { Transform } from 'node:stream';
4+
import { Readable, Transform } from 'node:stream';
55
import { KeysService } from './keys.service';
66
import { ConfigService } from '../services/config.service';
77
import { StreamUtils } from '../utils/stream.utils';
@@ -116,12 +116,26 @@ export class CryptoService {
116116
return Buffer.concat([decipher.update(contentsToDecrypt), decipher.final()]).toString('utf8');
117117
};
118118

119-
public async decryptStream(
119+
public encryptStreamInParts = (
120+
readable: Readable,
121+
cipher: Transform,
122+
size: number,
123+
parts: number,
124+
): Transform => {
125+
// We include a marginChunkSize because if we split the chunk directly, there will always be one more chunk left, this will cause a mismatch with the urls provided
126+
const marginChunkSize = 1024;
127+
const chunkSize = size / parts + marginChunkSize;
128+
const readableChunks = StreamUtils.streamReadableIntoChunks(readable, chunkSize);
129+
130+
return readableChunks.pipe(cipher);
131+
};
132+
133+
public decryptStream = (
120134
inputSlices: ReadableStream<Uint8Array>[],
121135
key: Buffer,
122136
iv: Buffer,
123137
startOffsetByte?: number,
124-
) {
138+
) => {
125139
let decipher: Decipher;
126140
if (startOffsetByte) {
127141
const aesBlockSize = 16;
@@ -164,7 +178,7 @@ export class CryptoService {
164178
});
165179

166180
return decryptedStream;
167-
}
181+
};
168182

169183
public getEncryptionTransform = (key: Buffer, iv: Buffer): Transform => {
170184
const cipher = createCipheriv('aes-256-ctr', key, iv);

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

Lines changed: 151 additions & 2 deletions
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;
@@ -73,7 +82,7 @@ export class NetworkFacade {
7382
if (rangeOptions) {
7483
startOffsetByte = rangeOptions.parsed.start;
7584
}
76-
fileStream = await this.cryptoService.decryptStream(
85+
fileStream = this.cryptoService.decryptStream(
7786
encryptedContentStreams,
7887
Buffer.from(key as ArrayBuffer),
7988
Buffer.from(iv as ArrayBuffer),
@@ -183,4 +192,144 @@ 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+
type Part = {
220+
PartNumber: number;
221+
ETag: string;
222+
};
223+
const fileParts: Part[] = [];
224+
225+
const onProgress = (partId: number, loadedBytes: number) => {
226+
if (!options?.progressCallback) return;
227+
partsUploadedBytes[partId] = loadedBytes;
228+
const currentTotalLoadedBytes = Object.values(partsUploadedBytes).reduce((a, p) => a + p, 0);
229+
const reportedProgress = Math.round((currentTotalLoadedBytes / size) * 100);
230+
options.progressCallback(reportedProgress);
231+
};
232+
233+
const encryptFile: EncryptFileFunction = async (_, key, iv) => {
234+
const encryptionCipher = this.cryptoService.getEncryptionTransform(
235+
Buffer.from(key as ArrayBuffer),
236+
Buffer.from(iv as ArrayBuffer),
237+
);
238+
const streamInParts = this.cryptoService.encryptStreamInParts(from, encryptionCipher, size, options.parts);
239+
encryptionTransform = streamInParts.pipe(hashStream);
240+
};
241+
242+
const uploadFileMultipart: UploadFileMultipartFunction = async (urls: string[]) => {
243+
let partIndex = 0;
244+
const limitConcurrency = 6;
245+
246+
const uploadPart = async (upload: UploadTask) => {
247+
const { etag } = await this.uploadService.uploadFile(upload.urlToUpload, upload.contentToUpload, {
248+
abortController: abortable,
249+
progressCallback: (loadedBytes: number) => {
250+
onProgress(upload.index, loadedBytes);
251+
},
252+
});
253+
254+
fileParts.push({
255+
ETag: etag,
256+
PartNumber: upload.index + 1,
257+
});
258+
};
259+
260+
const uploadQueue: QueueObject<UploadTask> = queue<UploadTask>(function (task, callback) {
261+
uploadPart(task)
262+
.then(() => {
263+
callback();
264+
})
265+
.catch((e) => {
266+
callback(e);
267+
});
268+
}, limitConcurrency);
269+
270+
for await (const chunk of encryptionTransform) {
271+
const part: Buffer = chunk;
272+
273+
if (uploadQueue.running() === limitConcurrency) {
274+
await uploadQueue.unsaturated();
275+
}
276+
277+
if (abortable.signal.aborted) {
278+
throw new Error('Upload cancelled by user');
279+
}
280+
281+
let errorAlreadyThrown = false;
282+
283+
uploadQueue
284+
.pushAsync({
285+
contentToUpload: part,
286+
urlToUpload: urls[partIndex],
287+
index: partIndex++,
288+
})
289+
.catch((err: Error) => {
290+
if (errorAlreadyThrown) return;
291+
292+
errorAlreadyThrown = true;
293+
if (err) {
294+
uploadQueue.kill();
295+
if (!abortable?.signal.aborted) {
296+
abortable.abort();
297+
}
298+
}
299+
});
300+
}
301+
302+
while (uploadQueue.running() > 0 || uploadQueue.length() > 0) {
303+
await uploadQueue.drain();
304+
}
305+
306+
hash = hashStream.getHash();
307+
const compareParts = (pA: Part, pB: Part) => pA.PartNumber - pB.PartNumber;
308+
const sortedParts = fileParts.sort(compareParts);
309+
return {
310+
hash: hash.toString('hex'),
311+
parts: sortedParts,
312+
};
313+
};
314+
315+
const uploadOperation = async () => {
316+
const uploadResult = await NetworkUpload.uploadMultipartFile(
317+
this.network,
318+
this.cryptoLib,
319+
bucketId,
320+
mnemonic,
321+
size,
322+
encryptFile,
323+
uploadFileMultipart,
324+
options.parts,
325+
);
326+
327+
return {
328+
fileId: uploadResult,
329+
hash: hash,
330+
};
331+
};
332+
333+
return [uploadOperation(), abortable];
334+
}
186335
}

src/services/network/upload.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { UploadOptions } from '../../types/network.types';
55
export class UploadService {
66
public static readonly instance: UploadService = new UploadService();
77

8-
async uploadFile(url: string, from: Readable, options: UploadOptions): Promise<{ etag: string }> {
8+
async uploadFile(url: string, from: Readable | Buffer, options: UploadOptions): Promise<{ etag: string }> {
99
const response = await axios.put(url, from, {
1010
signal: options.abortController?.signal,
1111
onUploadProgress: (progressEvent) => {

src/types/network.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,13 @@ export interface SelfsignedCert {
1717
cert: string | Buffer;
1818
key: string | Buffer;
1919
}
20+
21+
export interface UploadTask {
22+
contentToUpload: Buffer;
23+
urlToUpload: string;
24+
index: number;
25+
}
26+
27+
export interface UploadMultipartOptions extends UploadOptions {
28+
parts: number;
29+
}

src/utils/stream.utils.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ReadStream, WriteStream } from 'node:fs';
2-
import { Transform, TransformCallback } from 'node:stream';
2+
import { Readable, Transform, TransformCallback } from 'node:stream';
33

44
export class StreamUtils {
55
static readStreamToReadableStream(readStream: ReadStream): ReadableStream<Uint8Array> {
@@ -64,6 +64,48 @@ export class StreamUtils {
6464

6565
return stream;
6666
}
67+
68+
/**
69+
* Given a readable stream, it enqueues its parts into chunks as it is being read
70+
* @param readable Readable stream
71+
* @param chunkSize The chunkSize in bytes that we want each chunk to be
72+
* @returns A readable stream whose output is chunks of size chunkSize
73+
*/
74+
static streamReadableIntoChunks(readable: Readable, chunkSize: number): Readable {
75+
let buffer = Buffer.alloc(0);
76+
77+
const mergeBuffers = (buffer1: Buffer, buffer2: Buffer): Buffer => {
78+
return Buffer.concat([buffer1, buffer2]);
79+
};
80+
81+
const outputStream = new Readable({
82+
read() {
83+
// noop
84+
},
85+
});
86+
87+
readable.on('data', (chunk: Buffer) => {
88+
buffer = mergeBuffers(buffer, chunk);
89+
90+
while (buffer.length >= chunkSize) {
91+
outputStream.push(buffer.subarray(0, chunkSize));
92+
buffer = buffer.subarray(chunkSize);
93+
}
94+
});
95+
96+
readable.on('end', () => {
97+
if (buffer.length > 0) {
98+
outputStream.push(buffer);
99+
}
100+
outputStream.push(null); // Signal the end of the stream
101+
});
102+
103+
readable.on('error', (err) => {
104+
outputStream.destroy(err);
105+
});
106+
107+
return outputStream;
108+
}
67109
}
68110

69111
export class ProgressTransform extends Transform {

0 commit comments

Comments
 (0)