Skip to content

Commit c3afec3

Browse files
authored
feat(blob): Provide onUploadProgress({ loaded, total, percentage }) (#782)
* feat(blob): Provide onUploadProgress({ loaded, total, percentage }) This commit introduces an `onUploadProgress` callback to put/upload*. Here's how to use it: ```ts // also works with upload() const blob = await put('file.pdf', file, { onUploadProgress(event) { console.log(event.loaded, event.total, event.percentage); } }); ```
1 parent 6f654d9 commit c3afec3

34 files changed

+2432
-895
lines changed

.changeset/brown-years-heal.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@vercel/blob': minor
3+
'vercel-storage-integration-test-suite': minor
4+
---
5+
6+
Add onUploadProgress feature to put/upload
7+
8+
You can now track the upload progress in Node.js and all major browsers when
9+
using put/upload in multipart, non-multipart and client upload modes. Basically
10+
anywhere in our API you can upload a file, then you can follow the upload
11+
progress.
12+
13+
Here's a basic usage example:
14+
15+
```
16+
const blob = await put('big-file.pdf', file, {
17+
access: 'public',
18+
onUploadProgress(event) {
19+
console.log(event.loaded, event.total, event.percentage);
20+
}
21+
});
22+
```
23+
24+
Fixes #543
25+
Fixes #642

packages/blob/jest/setup.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,11 @@
22
// but they are available everywhere else.
33
// See https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest
44
const { TextEncoder, TextDecoder } = require('node:util');
5+
// eslint-disable-next-line import/order -- On purpose to make requiring undici work
6+
const { ReadableStream } = require('node:stream/web');
57

6-
Object.assign(global, { TextDecoder, TextEncoder });
8+
Object.assign(global, { TextDecoder, TextEncoder, ReadableStream });
9+
10+
const { Request, Response } = require('undici');
11+
12+
Object.assign(global, { Request, Response });

packages/blob/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,24 @@
6363
"async-retry": "^1.3.3",
6464
"bytes": "^3.1.2",
6565
"is-buffer": "^2.0.5",
66+
"is-node-process": "^1.2.0",
67+
"throttleit": "^2.1.0",
6668
"undici": "^5.28.4"
6769
},
6870
"devDependencies": {
6971
"@edge-runtime/jest-environment": "2.3.10",
7072
"@edge-runtime/types": "2.2.9",
71-
"@types/async-retry": "1.4.8",
73+
"@types/async-retry": "1.4.9",
7274
"@types/bytes": "3.1.4",
73-
"@types/jest": "29.5.13",
74-
"@types/node": "22.7.3",
75+
"@types/jest": "29.5.14",
76+
"@types/node": "22.9.0",
7577
"eslint": "8.56.0",
7678
"eslint-config-custom": "workspace:*",
7779
"jest": "29.7.0",
7880
"jest-environment-jsdom": "29.7.0",
7981
"ts-jest": "29.2.5",
8082
"tsconfig": "workspace:*",
81-
"tsup": "8.3.0"
83+
"tsup": "8.3.5"
8284
},
8385
"engines": {
8486
"node": ">=16.14"

packages/blob/src/api.ts

Lines changed: 125 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
import type { RequestInit, Response } from 'undici';
2-
import { fetch } from 'undici';
1+
import type { Response } from 'undici';
32
import retry from 'async-retry';
3+
import isNetworkError from './is-network-error';
44
import { debug } from './debug';
5-
import type { BlobCommandOptions } from './helpers';
6-
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
5+
import type {
6+
BlobCommandOptions,
7+
BlobRequestInit,
8+
WithUploadProgress,
9+
} from './helpers';
10+
import {
11+
BlobError,
12+
computeBodyLength,
13+
getApiUrl,
14+
getTokenFromOptionsOrEnv,
15+
} from './helpers';
16+
import { blobRequest } from './request';
17+
import { DOMException } from './dom-exception';
718

819
// maximum pathname length is:
920
// 1024 (provider limit) - 26 chars (vercel internal suffixes) - 31 chars (blob `-randomId` suffix) = 967
@@ -132,20 +143,6 @@ function getApiVersion(): string {
132143
return `${versionOverride ?? BLOB_API_VERSION}`;
133144
}
134145

135-
function getApiUrl(pathname = ''): string {
136-
let baseUrl = null;
137-
try {
138-
// wrapping this code in a try/catch as this function is used in the browser and Vite doesn't define the process.env.
139-
// As this varaible is NOT used in production, it will always default to production endpoint
140-
baseUrl =
141-
process.env.VERCEL_BLOB_API_URL ||
142-
process.env.NEXT_PUBLIC_VERCEL_BLOB_API_URL;
143-
} catch {
144-
// noop
145-
}
146-
return `${baseUrl || 'https://blob.vercel-storage.com'}${pathname}`;
147-
}
148-
149146
function getRetries(): number {
150147
try {
151148
const retries = process.env.VERCEL_BLOB_RETRIES || '10';
@@ -175,7 +172,6 @@ async function getBlobError(
175172

176173
try {
177174
const data = (await response.json()) as BlobApiError;
178-
179175
code = data.error?.code ?? 'unknown_error';
180176
message = data.error?.message;
181177
} catch {
@@ -254,8 +250,8 @@ async function getBlobError(
254250

255251
export async function requestApi<TResponse>(
256252
pathname: string,
257-
init: RequestInit,
258-
commandOptions: BlobCommandOptions | undefined,
253+
init: BlobRequestInit,
254+
commandOptions: (BlobCommandOptions & WithUploadProgress) | undefined,
259255
): Promise<TResponse> {
260256
const apiVersion = getApiVersion();
261257
const token = getTokenFromOptionsOrEnv(commandOptions);
@@ -264,23 +260,75 @@ export async function requestApi<TResponse>(
264260
const [, , , storeId = ''] = token.split('_');
265261
const requestId = `${storeId}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
266262
let retryCount = 0;
263+
let bodyLength = 0;
264+
let totalLoaded = 0;
265+
const sendBodyLength =
266+
commandOptions?.onUploadProgress || shouldUseXContentLength();
267+
268+
if (
269+
init.body &&
270+
// 1. For upload progress we always need to know the total size of the body
271+
// 2. In development we need the header for put() to work correctly when passing a stream
272+
sendBodyLength
273+
) {
274+
bodyLength = computeBodyLength(init.body);
275+
}
276+
277+
if (commandOptions?.onUploadProgress) {
278+
commandOptions.onUploadProgress({
279+
loaded: 0,
280+
total: bodyLength,
281+
percentage: 0,
282+
});
283+
}
267284

268285
const apiResponse = await retry(
269286
async (bail) => {
270287
let res: Response;
271288

272289
// try/catch here to treat certain errors as not-retryable
273290
try {
274-
res = await fetch(getApiUrl(pathname), {
275-
...init,
276-
headers: {
277-
'x-api-blob-request-id': requestId,
278-
'x-api-blob-request-attempt': String(retryCount),
279-
'x-api-version': apiVersion,
280-
authorization: `Bearer ${token}`,
281-
...extraHeaders,
282-
...init.headers,
291+
res = await blobRequest({
292+
input: getApiUrl(pathname),
293+
init: {
294+
...init,
295+
headers: {
296+
'x-api-blob-request-id': requestId,
297+
'x-api-blob-request-attempt': String(retryCount),
298+
'x-api-version': apiVersion,
299+
...(sendBodyLength
300+
? { 'x-content-length': String(bodyLength) }
301+
: {}),
302+
authorization: `Bearer ${token}`,
303+
...extraHeaders,
304+
...init.headers,
305+
},
283306
},
307+
onUploadProgress: commandOptions?.onUploadProgress
308+
? (loaded) => {
309+
const total = bodyLength !== 0 ? bodyLength : loaded;
310+
totalLoaded = loaded;
311+
const percentage =
312+
bodyLength > 0
313+
? Number(((loaded / total) * 100).toFixed(2))
314+
: 0;
315+
316+
// Leave percentage 100 for the end of request
317+
if (percentage === 100 && bodyLength > 0) {
318+
return;
319+
}
320+
321+
commandOptions.onUploadProgress?.({
322+
loaded,
323+
// When passing a stream to put(), we have no way to know the total size of the body.
324+
// Instead of defining total as total?: number we decided to set the total to the currently
325+
// loaded number. This is not inaccurate and way more practical for DX.
326+
// Passing down a stream to put() is very rare
327+
total,
328+
percentage,
329+
});
330+
}
331+
: undefined,
284332
});
285333
} catch (error) {
286334
// if the request was aborted, don't retry
@@ -289,6 +337,18 @@ export async function requestApi<TResponse>(
289337
return;
290338
}
291339

340+
// We specifically target network errors because fetch network errors are regular TypeErrors
341+
// We want to retry for network errors, but not for other TypeErrors
342+
if (isNetworkError(error)) {
343+
throw error;
344+
}
345+
346+
// If we messed up the request part, don't even retry
347+
if (error instanceof TypeError) {
348+
bail(error);
349+
return;
350+
}
351+
292352
// retry for any other erros thrown by fetch
293353
throw error;
294354
}
@@ -314,7 +374,10 @@ export async function requestApi<TResponse>(
314374
{
315375
retries: getRetries(),
316376
onRetry: (error) => {
317-
debug(`retrying API request to ${pathname}`, error.message);
377+
if (error instanceof Error) {
378+
debug(`retrying API request to ${pathname}`, error.message);
379+
}
380+
318381
retryCount = retryCount + 1;
319382
},
320383
},
@@ -324,6 +387,20 @@ export async function requestApi<TResponse>(
324387
throw new BlobUnknownError();
325388
}
326389

390+
// Calling onUploadProgress here has two benefits:
391+
// 1. It ensures 100% is only reached at the end of the request. While otherwise you can reach 100%
392+
// before the request is fully done, as we only really measure what gets sent over the wire, not what
393+
// has been processed by the server.
394+
// 2. It makes the uploadProgress "work" even in rare cases where fetch/xhr onprogress is not working
395+
// And in the case of multipart uploads it actually provides a simple progress indication (per part)
396+
if (commandOptions?.onUploadProgress) {
397+
commandOptions.onUploadProgress({
398+
loaded: totalLoaded,
399+
total: totalLoaded,
400+
percentage: 100,
401+
});
402+
}
403+
327404
return (await apiResponse.json()) as TResponse;
328405
}
329406

@@ -333,20 +410,31 @@ function getProxyThroughAlternativeApiHeaderFromEnv(): {
333410
const extraHeaders: Record<string, string> = {};
334411

335412
try {
336-
if ('VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API' in process.env) {
413+
if (
414+
'VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API' in process.env &&
415+
process.env.VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API !== undefined
416+
) {
337417
extraHeaders['x-proxy-through-alternative-api'] =
338-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know it's here from the if
339-
process.env.VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API!;
418+
process.env.VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API;
340419
} else if (
341-
'NEXT_PUBLIC_VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API' in process.env
420+
'NEXT_PUBLIC_VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API' in process.env &&
421+
process.env.NEXT_PUBLIC_VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API !==
422+
undefined
342423
) {
343424
extraHeaders['x-proxy-through-alternative-api'] =
344-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know it's here from the if
345-
process.env.NEXT_PUBLIC_VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API!;
425+
process.env.NEXT_PUBLIC_VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API;
346426
}
347427
} catch {
348428
// noop
349429
}
350430

351431
return extraHeaders;
352432
}
433+
434+
function shouldUseXContentLength(): boolean {
435+
try {
436+
return process.env.VERCEL_BLOB_USE_X_CONTENT_LENGTH === '1';
437+
} catch {
438+
return false;
439+
}
440+
}

packages/blob/src/client.browser.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ describe('client', () => {
8888
'https://blob.vercel-storage.com/foo.txt',
8989
{
9090
body: 'Test file data',
91-
duplex: 'half',
9291
headers: {
9392
authorization: 'Bearer vercel_blob_client_fake_123',
9493
'x-api-blob-request-attempt': '0',
@@ -232,7 +231,6 @@ describe('client', () => {
232231
'x-mpu-part-number': '1',
233232
},
234233
method: 'POST',
235-
duplex: 'half',
236234
signal: internalAbortSignal,
237235
},
238236
);
@@ -252,7 +250,6 @@ describe('client', () => {
252250
'x-mpu-part-number': '2',
253251
},
254252
method: 'POST',
255-
duplex: 'half',
256253
signal: internalAbortSignal,
257254
},
258255
);
@@ -376,7 +373,6 @@ describe('client', () => {
376373
'x-mpu-part-number': '1',
377374
},
378375
method: 'POST',
379-
duplex: 'half',
380376
signal: internalAbortSignal,
381377
},
382378
);
@@ -396,7 +392,6 @@ describe('client', () => {
396392
'x-mpu-part-number': '2',
397393
},
398394
method: 'POST',
399-
duplex: 'half',
400395
signal: internalAbortSignal,
401396
},
402397
);

packages/blob/src/client.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { IncomingMessage } from 'node:http';
55
// the `undici` module will be replaced with https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
66
// for browser contexts. See ./undici-browser.js and ./package.json
77
import { fetch } from 'undici';
8-
import type { BlobCommandOptions } from './helpers';
8+
import type { BlobCommandOptions, WithUploadProgress } from './helpers';
99
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
1010
import { createPutMethod } from './put';
1111
import type { PutBlobResult } from './put-helpers';
@@ -42,7 +42,9 @@ export interface ClientTokenOptions {
4242
}
4343

4444
// shared interface for put and upload
45-
interface ClientCommonPutOptions extends ClientCommonCreateBlobOptions {
45+
interface ClientCommonPutOptions
46+
extends ClientCommonCreateBlobOptions,
47+
WithUploadProgress {
4648
/**
4749
* Whether to use multipart upload. Use this when uploading large files. It will split the file into multiple parts, upload them in parallel and retry failed parts.
4850
*/
@@ -89,7 +91,7 @@ export const put = createPutMethod<ClientPutCommandOptions>({
8991
// vercelBlob. createMultipartUpload()
9092
// vercelBlob. uploadPart()
9193
// vercelBlob. completeMultipartUpload()
92-
// vercelBlob. createMultipartUploaded()
94+
// vercelBlob. createMultipartUploader()
9395

9496
export type ClientCreateMultipartUploadCommandOptions =
9597
ClientCommonCreateBlobOptions & ClientTokenOptions;
@@ -110,7 +112,8 @@ export const createMultipartUploader =
110112

111113
type ClientMultipartUploadCommandOptions = ClientCommonCreateBlobOptions &
112114
ClientTokenOptions &
113-
CommonMultipartUploadOptions;
115+
CommonMultipartUploadOptions &
116+
WithUploadProgress;
114117

115118
export const uploadPart =
116119
createUploadPartMethod<ClientMultipartUploadCommandOptions>({

0 commit comments

Comments
 (0)