diff --git a/docs/uploader/xhr.mdx b/docs/uploader/xhr.mdx index 1b1a429ff6..18f4801da6 100644 --- a/docs/uploader/xhr.mdx +++ b/docs/uploader/xhr.mdx @@ -148,6 +148,15 @@ The function syntax is not available when [`bundle`](#bundle) is set to `true`. ::: +:::note + +Failed requests are retried with the same headers. If you want to change the +headers on retry, +[such as refreshing an auth token](#how-can-I-refresh-auth-tokens-after-they-expire), +you can use [`onBeforeRequest`](#onbeforerequest). + +::: + #### `bundle` Send all files in a single multipart request (`boolean`, default: `false`). @@ -176,92 +185,6 @@ uppy.setFileState(fileID, { }); ``` -#### `validateStatus` - -Check if the response was successful (`function`, default: -`(status, responseText, response) => boolean`). - -- By default, responses with a 2xx HTTP status code are considered successful. -- When `true`, [`getResponseData()`](#getResponseData-responseText-response) - will be called and the upload will be marked as successful. -- When `false`, both - [`getResponseData()`](#getResponseData-responseText-response) and - [`getResponseError()`](#getResponseError-responseText-response) will be called - and the upload will be marked as unsuccessful. - -##### Parameters - -- The `statusCode` is the numeric HTTP status code returned by the endpoint. -- The `responseText` is the XHR endpoint response as a string. -- `response` is the [XMLHttpRequest][] object. - -:::note - -This option is only used for **local** uploads. Uploads from remote providers -like Google Drive or Instagram do not support this and will always use the -default. - -::: - -#### `getResponseData` - -Extract the response data from the successful upload (`function`, default: -`(responseText, response) => void`). - -- `responseText` is the XHR endpoint response as a string. -- `response` is the [XMLHttpRequest][] object. - -JSON is handled automatically, so you should only use this if the endpoint -responds with a different format. For example, an endpoint that responds with an -XML document: - -```js -function getResponseData(responseText, response) { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(responseText, 'text/xml'); - return { - url: xmlDoc.querySelector('Location').textContent, - }; -} -``` - -:::note - -This response data will be available on the file’s `.response` property and will -be emitted in the [`upload-success`][uppy.upload-success] event. - -::: - -:::note - -When uploading files from remote providers such as Dropbox or Instagram, -Companion sends upload response data to the client. This is made available in -the `getResponseData()` function as well. The `response` object from Companion -has some properties named after their [XMLHttpRequest][] counterparts. - -::: - -#### `getResponseError` - -Extract the error from the failed upload (`function`, default: -`(responseText, response) => void`). - -For example, if the endpoint responds with a JSON object containing a -`{ message }` property, this would show that message to the user: - -```js -function getResponseError(responseText, response) { - return new Error(JSON.parse(responseText).message); -} -``` - -#### `responseUrlFieldName` - -The field name containing the location of the uploaded file (`string`, default: -`'url'`). - -This is returned by [`getResponseData()`](#getResponseData). - #### `timeout: 30 * 1000` Abort the connection if no upload progress events have been received for this @@ -291,6 +214,26 @@ by browsers, so it’s recommended to use one of those. Indicates whether cross-site Access-Control requests should be made using credentials (`boolean`, default: `false`). +### `onBeforeRequest` + +An optional function that will be called before a HTTP request is sent out +(`(xhr: XMLHttpRequest, retryCount: number) => void | Promise`). + +### `shouldRetry` + +An optional function called once an error appears and before retrying +(`(xhr: XMLHttpRequesT) => boolean`). + +The amount of retries is 3, even if you continue to return `true`. The default +behavior uses +[exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) with a +maximum of 3 retries. + +### `onAfterResponse` + +An optional function that will be called after a HTTP response has been received +(`(xhr: XMLHttpRequest, retryCount: number) => void | Promise`). + #### `locale: {}` ```js @@ -304,6 +247,37 @@ export default { ## Frequently Asked Questions +### How can I refresh auth tokens after they expire? + +```js +import Uppy from '@uppy/core'; +import XHR from '@uppy/xhr-upload'; + +let token = null; + +async function getAuthToken() { + const res = await fetch('/auth/token'); + const json = await res.json(); + return json.token; +} + +new Uppy().use(XHR, { + endpoint: '', + // Called again for every retry too. + async onBeforeRequest(xhr) { + if (!token) { + token = await getAuthToken(); + } + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + }, + async onAfterResponse(xhr) { + if (xhr.status === 401) { + token = await getAuthToken(); + } + }, +}); +``` + ### How to send along meta data with the upload? When using XHRUpload with [`formData: true`](#formData-true), file metadata is @@ -384,13 +358,10 @@ move_uploaded_file($file_path, $_SERVER['DOCUMENT_ROOT'] . '/img/' . basename($f ``` [formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData -[xmlhttprequest]: - https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest [xhr.timeout]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/timeout [xhr.responsetype]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType -[uppy.upload-success]: /docs/uppy/#upload-success [uppy file]: /docs/uppy#working-with-uppy-files [php.file-upload]: https://secure.php.net/manual/en/features.file-upload.php [php.multiple]: diff --git a/packages/@uppy/utils/src/fetcher.ts b/packages/@uppy/utils/src/fetcher.ts index 619e8a501c..c600f71552 100644 --- a/packages/@uppy/utils/src/fetcher.ts +++ b/packages/@uppy/utils/src/fetcher.ts @@ -38,7 +38,7 @@ export type FetcherOptions = { shouldRetry?: (xhr: XMLHttpRequest) => boolean /** Called after the response has succeeded or failed but before the promise is resolved. */ - onAfterRequest?: ( + onAfterResponse?: ( xhr: XMLHttpRequest, retryCount: number, ) => void | Promise @@ -67,7 +67,7 @@ export function fetcher( onBeforeRequest = noop, onUploadProgress = noop, shouldRetry = () => true, - onAfterRequest = noop, + onAfterResponse = noop, onTimeout = noop, responseType, retries = 3, @@ -99,7 +99,7 @@ export function fetcher( }) xhr.onload = async () => { - await onAfterRequest(xhr, retryCount) + await onAfterResponse(xhr, retryCount) if (xhr.status >= 200 && xhr.status < 300) { timer.done() diff --git a/packages/@uppy/xhr-upload/src/index.test.ts b/packages/@uppy/xhr-upload/src/index.test.ts index 62271157e6..7ccfd812af 100644 --- a/packages/@uppy/xhr-upload/src/index.test.ts +++ b/packages/@uppy/xhr-upload/src/index.test.ts @@ -4,88 +4,44 @@ import Core from '@uppy/core' import XHRUpload from './index.ts' describe('XHRUpload', () => { - describe('getResponseData', () => { - it('has the XHRUpload options as its `this`', () => { - nock('https://fake-endpoint.uppy.io') - .defaultReplyHeaders({ - 'access-control-allow-method': 'POST', - 'access-control-allow-origin': '*', - }) - .options('/') - .reply(200, {}) - .post('/') - .reply(200, {}) - - const core = new Core() - const getResponseData = vi.fn(function getResponseData() { - // @ts-expect-error TS can't know the type - expect(this.some).toEqual('option') - return {} - }) - core.use(XHRUpload, { - id: 'XHRUpload', - endpoint: 'https://fake-endpoint.uppy.io', - // @ts-expect-error that option does not exist - some: 'option', - getResponseData, - }) - core.addFile({ - type: 'image/png', - source: 'test', - name: 'test.jpg', - data: new Blob([new Uint8Array(8192)]), - }) - - return core.upload().then(() => { - expect(getResponseData).toHaveBeenCalled() + it('should leverage hooks from fetcher', () => { + nock('https://fake-endpoint.uppy.io') + .defaultReplyHeaders({ + 'access-control-allow-method': 'POST', + 'access-control-allow-origin': '*', }) - }) - }) + .options('/') + .reply(204, {}) + .post('/') + .reply(401, {}) + .options('/') + .reply(204, {}) + .post('/') + .reply(200, {}) - describe('validateStatus', () => { - it('emit upload error under status code 200', () => { - nock('https://fake-endpoint.uppy.io') - .defaultReplyHeaders({ - 'access-control-allow-method': 'POST', - 'access-control-allow-origin': '*', - }) - .options('/') - .reply(200, {}) - .post('/') - .reply(200, { - code: 40000, - message: 'custom upload error', - }) + const core = new Core() + const shouldRetry = vi.fn(() => true) + const onBeforeRequest = vi.fn(() => {}) + const onAfterResponse = vi.fn(() => {}) - const core = new Core() - const validateStatus = vi.fn((status, responseText) => { - return JSON.parse(responseText).code !== 40000 - }) - - core.use(XHRUpload, { - id: 'XHRUpload', - endpoint: 'https://fake-endpoint.uppy.io', - // @ts-expect-error that option doesn't exist - some: 'option', - validateStatus, - getResponseError(responseText) { - return JSON.parse(responseText).message - }, - }) - core.addFile({ - type: 'image/png', - source: 'test', - name: 'test.jpg', - data: new Blob([new Uint8Array(8192)]), - }) + core.use(XHRUpload, { + id: 'XHRUpload', + endpoint: 'https://fake-endpoint.uppy.io', + shouldRetry, + onBeforeRequest, + onAfterResponse, + }) + core.addFile({ + type: 'image/png', + source: 'test', + name: 'test.jpg', + data: new Blob([new Uint8Array(8192)]), + }) - return core.upload().then((result) => { - expect(validateStatus).toHaveBeenCalled() - expect(result!.failed!.length).toBeGreaterThan(0) - result!.failed!.forEach((file) => { - expect(file.error).toEqual('custom upload error') - }) - }) + return core.upload().then(() => { + expect(shouldRetry).toHaveBeenCalledTimes(1) + expect(onAfterResponse).toHaveBeenCalledTimes(2) + expect(onBeforeRequest).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/@uppy/xhr-upload/src/index.ts b/packages/@uppy/xhr-upload/src/index.ts index 4ea7b10af8..6b3124f813 100644 --- a/packages/@uppy/xhr-upload/src/index.ts +++ b/packages/@uppy/xhr-upload/src/index.ts @@ -10,7 +10,7 @@ import { } from '@uppy/utils/lib/RateLimitedQueue' import NetworkError from '@uppy/utils/lib/NetworkError' import isNetworkError from '@uppy/utils/lib/isNetworkError' -import { fetcher } from '@uppy/utils/lib/fetcher' +import { fetcher, type FetcherOptions } from '@uppy/utils/lib/fetcher' import { filterNonFailedFiles, filterFilesToEmitUploadStarted, @@ -54,16 +54,11 @@ export interface XhrUploadOpts limit?: number responseType?: XMLHttpRequestResponseType withCredentials?: boolean - validateStatus?: ( - status: number, - body: string, - xhr: XMLHttpRequest, - ) => boolean - getResponseData?: (body: string, xhr: XMLHttpRequest) => B - getResponseError?: (body: string, xhr: XMLHttpRequest) => Error | NetworkError - allowedMetaFields?: string[] | boolean + onBeforeRequest?: FetcherOptions['onBeforeRequest'] + shouldRetry?: FetcherOptions['shouldRetry'] + onAfterResponse?: FetcherOptions['onAfterResponse'] + allowedMetaFields?: boolean | string[] bundle?: boolean - responseUrlFieldName?: string } function buildResponseError( @@ -106,37 +101,12 @@ const defaultOptions = { fieldName: 'file', method: 'post', allowedMetaFields: true, - responseUrlFieldName: 'url', bundle: false, headers: {}, timeout: 30 * 1000, limit: 5, withCredentials: false, responseType: '', - getResponseData(responseText) { - let parsedResponse = {} - try { - parsedResponse = JSON.parse(responseText) - } catch { - // ignore - } - // We don't have access to the B (Body) generic here - // so we have to cast it to any. The user facing types - // remain correct, this is only to please the merging of default options. - return parsedResponse as any - }, - getResponseError(_, response) { - let error = new Error('Upload error') - - if (isNetworkError(response)) { - error = new NetworkError(error, response) - } - - return error - }, - validateStatus(status) { - return status >= 200 && status < 300 - }, } satisfies Partial> type Opts = DefinePluginOpts< @@ -215,6 +185,9 @@ export default class XHRUpload< try { const res = await fetcher(url, { ...options, + onBeforeRequest: this.opts.onBeforeRequest, + shouldRetry: this.opts.shouldRetry, + onAfterResponse: this.opts.onAfterResponse, onTimeout: (timeout) => { const seconds = Math.ceil(timeout / 1000) const error = new Error(this.i18n('uploadStalled', { seconds })) @@ -235,14 +208,8 @@ export default class XHRUpload< }, }) - if (!this.opts.validateStatus(res.status, res.responseText, res)) { - throw new NetworkError(res.statusText, res) - } - - const body = this.opts.getResponseData(res.responseText, res) - const uploadURL = body?.[this.opts.responseUrlFieldName] as - | string - | undefined + const body = JSON.parse(res.responseText) as B + const uploadURL = typeof body?.url === 'string' ? body.url : undefined for (const file of files) { this.uppy.emit('upload-success', file, { @@ -259,12 +226,13 @@ export default class XHRUpload< } if (error instanceof NetworkError) { const request = error.request! - const customError = buildResponseError( - request, - this.opts.getResponseError(request.responseText, request), - ) + for (const file of files) { - this.uppy.emit('upload-error', file, customError) + this.uppy.emit( + 'upload-error', + file, + buildResponseError(request, error), + ) } }