Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 16 commits into from May 9, 2024
147 changes: 58 additions & 89 deletions docs/uploader/xhr.mdx
Expand Up @@ -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`.
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
Murderlon marked this conversation as resolved.
Show resolved Hide resolved

:::

#### `bundle`

Send all files in a single multipart request (`boolean`, default: `false`).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -304,6 +247,35 @@ 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;

async function getAuthToken(refresh = false) {
if (!refresh && token) {
return token;
}
// fetch it
}

new Uppy().use(XHR, {
endpoint: '<your-endpoint>',
async onBeforeRequest(xhr) {
const token = await getAuthToken();
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
},
async onAfterResponse(xhr) {
if (xhr.status === 401) {
await getAuthToken(true);
mifi marked this conversation as resolved.
Show resolved Hide resolved
}
},
});
```

### How to send along meta data with the upload?

When using XHRUpload with [`formData: true`](#formData-true), file metadata is
Expand Down Expand Up @@ -384,13 +356,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]:
Expand Down
6 changes: 3 additions & 3 deletions packages/@uppy/utils/src/fetcher.ts
Expand Up @@ -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>
Expand Down Expand Up @@ -67,7 +67,7 @@ export function fetcher(
onBeforeRequest = noop,
onUploadProgress = noop,
shouldRetry = () => true,
onAfterRequest = noop,
onAfterResponse = noop,
onTimeout = noop,
responseType,
retries = 3,
Expand Down Expand Up @@ -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()
Expand Down
112 changes: 34 additions & 78 deletions packages/@uppy/xhr-upload/src/index.test.ts
Expand Up @@ -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)
})
})

Expand Down