1- import type { RequestInit , Response } from 'undici' ;
2- import { fetch } from 'undici' ;
1+ import type { Response } from 'undici' ;
32import retry from 'async-retry' ;
3+ import isNetworkError from './is-network-error' ;
44import { 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-
149146function 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
255251export 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+ }
0 commit comments