Ver código fonte

@uppy/xhr-upload: introduce hooks similar to tus (#5094)

Merlijn Vos 11 meses atrás
pai
commit
7a9eb87d43

+ 60 - 89
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<void>`).
+
+### `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<void>`).
+
 #### `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: '<your-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]:

+ 3 - 3
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<void>
@@ -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()

+ 34 - 78
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)
     })
   })
 

+ 16 - 48
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<M extends Meta, B extends Body>
   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<XhrUploadOpts<any, any>>
 
 type Opts<M extends Meta, B extends Body> = 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),
+              )
             }
           }