Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
d70a84e
feat(blob): Provide onUploadProgress({ loaded, total, percentage })
vvo Oct 15, 2024
0e11640
update
vvo Oct 24, 2024
33085b4
lint
vvo Oct 24, 2024
10efcfa
add 0 percent loading before request starts
vvo Oct 24, 2024
9a4a7fd
fix https://github.com/vercel/storage/pull/782#discussion_r1814651919
vvo Oct 24, 2024
59e01f6
Fix https://github.com/vercel/storage/pull/782\#discussion_r1814659658
vvo Oct 24, 2024
6e6d7b9
fix https://github.com/vercel/storage/pull/782\#discussion_r1814676109
vvo Oct 24, 2024
57d0270
Fix https://github.com/vercel/storage/pull/782\#discussion_r1814676691
vvo Oct 24, 2024
4fbdc0e
Update packages/blob/src/helpers.ts
vvo Oct 24, 2024
c69d7e7
fix types
vvo Oct 24, 2024
808ed45
Merge branch 'feat/blob/on-progress' of github.com:vercel/storage int…
vvo Oct 24, 2024
7aa175b
move fetch to its own file
vvo Oct 24, 2024
c8d7567
xhr fallback + refactor
vvo Oct 25, 2024
00727af
Update packages/blob/src/helpers.ts
vvo Oct 25, 2024
45151d9
style
vvo Oct 25, 2024
3ee617d
fix
vvo Oct 25, 2024
237165c
cosmetic changesgp
vvo Oct 25, 2024
703090e
add debugging msg
vvo Oct 25, 2024
84cd3f0
remove
vvo Oct 25, 2024
248b360
fix tests
vvo Oct 25, 2024
ba3a851
update multipart
vvo Oct 25, 2024
912f988
fix lint
vvo Oct 25, 2024
e64dd26
make progress events during fetch async
vvo Oct 25, 2024
2c8e0ba
add tests, expose event and callback
vvo Oct 25, 2024
a0c7521
changeset
vvo Oct 25, 2024
5f0ad1c
lint
vvo Oct 25, 2024
3c596cb
update
vvo Oct 25, 2024
9d6ddac
update
vvo Oct 25, 2024
4629f9a
update
vvo Oct 25, 2024
a74ff98
update
vvo Oct 25, 2024
37e0ae5
make streams and progress compatible
vvo Nov 5, 2024
639a0ac
retry on network errors
vvo Nov 5, 2024
26462db
comments around api code
vvo Nov 5, 2024
628929f
CMON js
vvo Nov 5, 2024
42146d8
C'MON JS
vvo Nov 5, 2024
aa99c22
update
vvo Nov 5, 2024
a6fd11f
Merge branch 'main' into feat/blob/on-progress
vvo Nov 5, 2024
5bff1ec
downgrade undici to allow for old Next.js and Node.js versions
vvo Nov 5, 2024
a28959f
update
vvo Nov 5, 2024
aabdf54
update
vvo Nov 5, 2024
91d0478
node 16
vvo Nov 6, 2024
d24da2e
node 16 updates
vvo Nov 6, 2024
682d4ba
update
vvo Nov 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/blob/jest/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@
// but they are available everywhere else.
// See https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest
const { TextEncoder, TextDecoder } = require('node:util');
// eslint-disable-next-line import/order -- On purpose to make requiring undici work
const { ReadableStream } = require('node:stream/web');

Object.assign(global, { TextDecoder, TextEncoder });
Object.assign(global, { TextDecoder, TextEncoder, ReadableStream });

const { Request } = require('undici');

Object.assign(global, { Request });
3 changes: 2 additions & 1 deletion packages/blob/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"async-retry": "^1.3.3",
"bytes": "^3.1.2",
"is-buffer": "^2.0.5",
"undici": "^5.28.4"
"throttleit": "^2.1.0",
"undici": "^6.20.1"
},
"devDependencies": {
"@edge-runtime/jest-environment": "2.3.10",
Expand Down
127 changes: 108 additions & 19 deletions packages/blob/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import type { RequestInit, Response } from 'undici';
import type { BodyInit, RequestInit, Response } from 'undici';
import { fetch } from 'undici';
import retry from 'async-retry';
import { debug } from './debug';
import type { BlobCommandOptions } from './helpers';
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
import type { BlobCommandOptions, WithUploadProgress } from './helpers';
import {
BlobError,
computeBodyLength,
createChunkTransformStream,
getApiUrl,
getTokenFromOptionsOrEnv,
isStream,
supportsRequestStreams,
} from './helpers';
import { toReadableStream } from './multipart/helpers';
import type { PutBody } from './put-helpers';

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

function getApiUrl(pathname = ''): string {
let baseUrl = null;
try {
// wrapping this code in a try/catch as this function is used in the browser and Vite doesn't define the process.env.
// As this varaible is NOT used in production, it will always default to production endpoint
baseUrl =
process.env.VERCEL_BLOB_API_URL ||
process.env.NEXT_PUBLIC_VERCEL_BLOB_API_URL;
} catch {
// noop
}
return `${baseUrl || 'https://blob.vercel-storage.com'}${pathname}`;
}

function getRetries(): number {
try {
const retries = process.env.VERCEL_BLOB_RETRIES || '10';
Expand Down Expand Up @@ -175,7 +171,6 @@ async function getBlobError(

try {
const data = (await response.json()) as BlobApiError;

code = data.error?.code ?? 'unknown_error';
message = data.error?.message;
} catch {
Expand Down Expand Up @@ -252,10 +247,12 @@ async function getBlobError(
return { code, error };
}

const CHUNK_SIZE = 64 * 1024;

export async function requestApi<TResponse>(
pathname: string,
init: RequestInit,
commandOptions: BlobCommandOptions | undefined,
commandOptions: (BlobCommandOptions & WithUploadProgress) | undefined,
Comment thread
vvo marked this conversation as resolved.
): Promise<TResponse> {
const apiVersion = getApiVersion();
const token = getTokenFromOptionsOrEnv(commandOptions);
Expand All @@ -268,20 +265,104 @@ export async function requestApi<TResponse>(
const apiResponse = await retry(
async (bail) => {
let res: Response;
let bodyLength: number | undefined;
let body: BodyInit | undefined;

if (
init.body &&
// 1. For upload progress we always need to know the total size of the body
// 2. In development we need the header for put() to work correctly when passing a stream
(commandOptions?.onUploadProgress || shouldUseXContentLength())
) {
bodyLength = computeBodyLength(init.body);
}

if (init.body) {
if (commandOptions?.onUploadProgress) {
if (supportsRequestStreams) {
Comment thread
vvo marked this conversation as resolved.
Outdated
// We transform the body to a stream here instead of at the call site
// So that on retries we can reuse the original body, otherwise we would not be able to reuse it
const stream = await toReadableStream(init.body as PutBody);

let loaded = 0;

const chunkTransformStream = createChunkTransformStream(
CHUNK_SIZE,
(newLoaded: number) => {
loaded += newLoaded;
const total = bodyLength ?? loaded;
Comment thread
vvo marked this conversation as resolved.
Outdated
const percentage = Number(((loaded / total) * 100).toFixed(2));

// Leave percentage 100 to end of request
if (percentage === 100) {
return;
}

commandOptions.onUploadProgress?.({
Comment thread
luismeyer marked this conversation as resolved.
loaded,
// When passing a stream to put(), we have no way to know the total size of the body.
Comment thread
luismeyer marked this conversation as resolved.
// Instead of defining total as total?: number we decided to set the total to the currently
// loaded number. This is not inaccurate and way more practical for DX.
// Passing down a stream to put() is very rare
total,
percentage,
});
},
);

body = stream.pipeThrough(chunkTransformStream);
} else {
body = init.body;
}
} else {
body = init.body;
}
}

// Only set duplex option when supported and dealing with a stream body
const duplex =
supportsRequestStreams && body && isStream(body as PutBody)
? 'half'
: undefined;

if (commandOptions?.onUploadProgress) {
commandOptions.onUploadProgress({
loaded: 0,
total: bodyLength ?? 0,
percentage: 0,
});
}

// try/catch here to treat certain errors as not-retryable
try {
res = await fetch(getApiUrl(pathname), {
...init,
...(init.body ? { body } : {}),
...(duplex ? { duplex } : {}),
headers: {
'x-api-blob-request-id': requestId,
'x-api-blob-request-attempt': String(retryCount),
'x-api-version': apiVersion,
...(bodyLength ? { 'x-content-length': String(bodyLength) } : {}),
authorization: `Bearer ${token}`,
...extraHeaders,
...init.headers,
},
});

// Calling onUploadProgress here has two benefits:
// 1. It ensures 100% is only reached at the end of the request. While otherwise you can reach 100%
// before the request is fully done, as we only really measure what gets sent over the wire, not what
// has been processed by the server.
// 2. It makes the uploadProgress "work" even for browsers not supporting request streams like Safari.
// And in the case of multipart uploads it actually provides a simple progress indication (per part)
if (commandOptions?.onUploadProgress) {
commandOptions.onUploadProgress({
loaded: bodyLength ?? 0,
total: bodyLength ?? 0,
percentage: 100,
});
}
} catch (error) {
// if the request was aborted, don't retry
if (error instanceof DOMException && error.name === 'AbortError') {
Expand Down Expand Up @@ -350,3 +431,11 @@ function getProxyThroughAlternativeApiHeaderFromEnv(): {

return extraHeaders;
}

function shouldUseXContentLength(): boolean {
try {
return process.env.VERCEL_BLOB_USE_X_CONTENT_LENGTH === '1';
} catch {
return false;
}
}
5 changes: 0 additions & 5 deletions packages/blob/src/client.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ describe('client', () => {
'https://blob.vercel-storage.com/foo.txt',
{
body: 'Test file data',
duplex: 'half',
headers: {
authorization: 'Bearer vercel_blob_client_fake_123',
'x-api-blob-request-attempt': '0',
Expand Down Expand Up @@ -232,7 +231,6 @@ describe('client', () => {
'x-mpu-part-number': '1',
},
method: 'POST',
duplex: 'half',
signal: internalAbortSignal,
},
);
Expand All @@ -252,7 +250,6 @@ describe('client', () => {
'x-mpu-part-number': '2',
},
method: 'POST',
duplex: 'half',
signal: internalAbortSignal,
},
);
Expand Down Expand Up @@ -376,7 +373,6 @@ describe('client', () => {
'x-mpu-part-number': '1',
},
method: 'POST',
duplex: 'half',
signal: internalAbortSignal,
},
);
Expand All @@ -396,7 +392,6 @@ describe('client', () => {
'x-mpu-part-number': '2',
},
method: 'POST',
duplex: 'half',
signal: internalAbortSignal,
},
);
Expand Down
11 changes: 7 additions & 4 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { IncomingMessage } from 'node:http';
// the `undici` module will be replaced with https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
// for browser contexts. See ./undici-browser.js and ./package.json
import { fetch } from 'undici';
import type { BlobCommandOptions } from './helpers';
import type { BlobCommandOptions, WithUploadProgress } from './helpers';
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
import { createPutMethod } from './put';
import type { PutBlobResult } from './put-helpers';
Expand Down Expand Up @@ -42,7 +42,9 @@ export interface ClientTokenOptions {
}

// shared interface for put and upload
interface ClientCommonPutOptions extends ClientCommonCreateBlobOptions {
interface ClientCommonPutOptions
extends ClientCommonCreateBlobOptions,
WithUploadProgress {
/**
* 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.
*/
Expand Down Expand Up @@ -89,7 +91,7 @@ export const put = createPutMethod<ClientPutCommandOptions>({
// vercelBlob. createMultipartUpload()
// vercelBlob. uploadPart()
// vercelBlob. completeMultipartUpload()
// vercelBlob. createMultipartUploaded()
// vercelBlob. createMultipartUploader()

export type ClientCreateMultipartUploadCommandOptions =
ClientCommonCreateBlobOptions & ClientTokenOptions;
Expand All @@ -110,7 +112,8 @@ export const createMultipartUploader =

type ClientMultipartUploadCommandOptions = ClientCommonCreateBlobOptions &
ClientTokenOptions &
CommonMultipartUploadOptions;
CommonMultipartUploadOptions &
WithUploadProgress;

export const uploadPart =
createUploadPartMethod<ClientMultipartUploadCommandOptions>({
Expand Down
Loading