Kaynağa Gözat

@uppy/aws-s3: add `endpoint` option (#5173)

Antoine du Hamel 10 ay önce
ebeveyn
işleme
087d8f07f0

+ 16 - 0
docs/guides/migration-guides.md

@@ -102,6 +102,22 @@ const { someThingMyBackendReturns } = uppy.getFile(id).response;
   about this in the
   [plugin docs](https://uppy.io/docs/aws-s3-multipart/#when-should-i-use-it).
 - Remove deprecated `prepareUploadParts` option.
+- Companion’s options (`companionUrl`, `companionHeaders`, and
+  `companionCookieRules`) are renamed to more generic names (`endpoint`,
+  `headers`, and `cookieRules`).
+
+  Using Companion with the `@uppy/aws-s3` plugin only makes sense if you already
+  need Companion for remote providers (such as Google Drive). When using your
+  own backend, you can let Uppy do all the heavy lifting on the client which it
+  would normally do for Companion, so you don’t have to implement that yourself.
+
+  As long as you return the JSON for the expected endpoints (see our
+  [server example](https://github.com/transloadit/uppy/blob/main/examples/aws-nodejs/index.js)),
+  you only need to set `endpoint`.
+
+  If you are using Companion, rename the options. If you have a lot of
+  client-side complexity (`createMultipartUpload`, `signPart`, etc), consider
+  letting Uppy do this for you.
 
 ### `@uppy/core`
 

+ 10 - 9
docs/uploader/aws-s3-multipart.mdx

@@ -166,7 +166,7 @@ import '@uppy/dashboard/dist/style.min.css';
 const uppy = new Uppy()
 	.use(Dashboard, { inline: true, target: 'body' })
 	.use(AwsS3, {
-		companionUrl: 'https://companion.uppy.io',
+		endpoint: 'https://companion.uppy.io',
 	});
 ```
 
@@ -215,22 +215,23 @@ uploaded.
 
 :::
 
-#### `companionUrl`
+#### `endpoint`
 
-URL to a [Companion](/docs/companion) instance (`string`, default: `null`).
+URL to your backend or to [Companion](/docs/companion) (`string`, default:
+`null`).
 
-#### `companionHeaders`
+#### `headers`
 
-Custom headers that should be sent along to [Companion](/docs/companion) on
-every request (`Object`, default: `{}`).
+Custom headers that should be sent along to the [`endpoint`](#endpoint) on every
+request (`Object`, default: `{}`).
 
-#### `companionCookiesRule`
+#### `cookiesRule`
 
 This option correlates to the
 [RequestCredentials value](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials)
 (`string`, default: `'same-origin'`).
 
-This tells the plugin whether to send cookies to [Companion](/docs/companion).
+This tells the plugin whether to send cookies to the [`endpoint`](#endpoint).
 
 #### `retryDelays`
 
@@ -432,7 +433,7 @@ upload sources), you can pass a boolean:
 ```js
 uppy.use(AwsS3, {
 	// This is an example using Companion:
-	companionUrl: 'http://companion.uppy.io',
+	endpoint: 'http://companion.uppy.io',
 	getTemporarySecurityCredentials: true,
 	shouldUseMultipart: (file) => file.size > 100 * 2 ** 20,
 });

+ 1 - 1
e2e/clients/dashboard-aws-multipart/app.js

@@ -9,7 +9,7 @@ const uppy = new Uppy()
   .use(Dashboard, { target: '#app', inline: true })
   .use(AwsS3Multipart, {
     limit: 2,
-    companionUrl: process.env.VITE_COMPANION_URL,
+    endpoint: process.env.VITE_COMPANION_URL,
     shouldUseMultipart: true,
   })
 

+ 1 - 1
e2e/clients/dashboard-aws/app.js

@@ -9,7 +9,7 @@ const uppy = new Uppy()
   .use(Dashboard, { target: '#app', inline: true })
   .use(AwsS3, {
     limit: 2,
-    companionUrl: process.env.VITE_COMPANION_URL,
+    endpoint: process.env.VITE_COMPANION_URL,
     shouldUseMultipart: false,
   })
 

+ 225 - 115
examples/aws-nodejs/index.js

@@ -2,6 +2,7 @@
 
 const path = require('node:path')
 const crypto = require('node:crypto')
+const { existsSync } = require('node:fs')
 require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') })
 
 const express = require('express')
@@ -9,6 +10,7 @@ const express = require('express')
 const app = express()
 
 const port = process.env.PORT ?? 8080
+const accessControlAllowOrigin = '*' // You should define the actual domain(s) that are allowed to make requests.
 const bodyParser = require('body-parser')
 
 const {
@@ -21,19 +23,14 @@ const {
   UploadPartCommand,
 } = require('@aws-sdk/client-s3')
 const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
-const {
-  STSClient,
-  GetFederationTokenCommand,
-} = require('@aws-sdk/client-sts')
+const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts')
 
 const policy = {
   Version: '2012-10-17',
   Statement: [
     {
       Effect: 'Allow',
-      Action: [
-        's3:PutObject',
-      ],
+      Action: ['s3:PutObject'],
       Resource: [
         `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`,
         `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`,
@@ -54,10 +51,10 @@ let stsClient
 
 const expiresIn = 900 // Define how long until a S3 signature expires.
 
-function getS3Client () {
+function getS3Client() {
   s3Client ??= new S3Client({
     region: process.env.COMPANION_AWS_REGION,
-    credentials : {
+    credentials: {
       accessKeyId: process.env.COMPANION_AWS_KEY,
       secretAccessKey: process.env.COMPANION_AWS_SECRET,
     },
@@ -65,10 +62,10 @@ function getS3Client () {
   return s3Client
 }
 
-function getSTSClient () {
+function getSTSClient() {
   stsClient ??= new STSClient({
     region: process.env.COMPANION_AWS_REGION,
-    credentials : {
+    credentials: {
       accessKeyId: process.env.COMPANION_AWS_KEY,
       secretAccessKey: process.env.COMPANION_AWS_SECRET,
     },
@@ -78,53 +75,61 @@ function getSTSClient () {
 
 app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json())
 
-app.get('/', (req, res) => {
-  const htmlPath = path.join(__dirname, 'public', 'index.html')
-  res.sendFile(htmlPath)
-})
-
-app.get('/drag', (req, res) => {
-  const htmlPath = path.join(__dirname, 'public', 'drag.html')
-  res.sendFile(htmlPath)
+app.get('/s3/sts', (req, res, next) => {
+  // Before giving the STS token to the client, you should first check is they
+  // are authorized to perform that operation, and if the request is legit.
+  // For the sake of simplification, we skip that check in this example.
+
+  getSTSClient()
+    .send(
+      new GetFederationTokenCommand({
+        Name: '123user',
+        // The duration, in seconds, of the role session. The value specified
+        // can range from 900 seconds (15 minutes) up to the maximum session
+        // duration set for the role.
+        DurationSeconds: expiresIn,
+        Policy: JSON.stringify(policy),
+      }),
+    )
+    .then((response) => {
+      // Test creating multipart upload from the server — it works
+      // createMultipartUploadYo(response)
+      res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
+      res.setHeader('Cache-Control', `public,max-age=${expiresIn}`)
+      res.json({
+        credentials: response.Credentials,
+        bucket: process.env.COMPANION_AWS_BUCKET,
+        region: process.env.COMPANION_AWS_REGION,
+      })
+    }, next)
 })
+const signOnServer = (req, res, next) => {
+  // Before giving the signature to the user, you should first check is they
+  // are authorized to perform that operation, and if the request is legit.
+  // For the sake of simplification, we skip that check in this example.
 
-app.get('/sts', (req, res, next) => {
-  getSTSClient().send(new GetFederationTokenCommand({
-    Name: '123user',
-    // The duration, in seconds, of the role session. The value specified
-    // can range from 900 seconds (15 minutes) up to the maximum session
-    // duration set for the role.
-    DurationSeconds: expiresIn,
-    Policy: JSON.stringify(policy),
-  })).then(response => {
-    // Test creating multipart upload from the server — it works
-    // createMultipartUploadYo(response)
-    res.setHeader('Access-Control-Allow-Origin', '*')
-    res.setHeader('Cache-Control', `public,max-age=${expiresIn}`)
-    res.json({
-      credentials: response.Credentials,
-      bucket: process.env.COMPANION_AWS_BUCKET,
-      region: process.env.COMPANION_AWS_REGION,
-    })
-  }, next)
-})
-app.post('/sign-s3', (req, res, next) => {
   const Key = `${crypto.randomUUID()}-${req.body.filename}`
   const { contentType } = req.body
 
-  getSignedUrl(getS3Client(), new PutObjectCommand({
-    Bucket: process.env.COMPANION_AWS_BUCKET,
-    Key,
-    ContentType: contentType,
-  }), { expiresIn }).then((url) => {
-    res.setHeader('Access-Control-Allow-Origin', '*')
+  getSignedUrl(
+    getS3Client(),
+    new PutObjectCommand({
+      Bucket: process.env.COMPANION_AWS_BUCKET,
+      Key,
+      ContentType: contentType,
+    }),
+    { expiresIn },
+  ).then((url) => {
+    res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
     res.json({
       url,
       method: 'PUT',
     })
     res.end()
   }, next)
-})
+}
+app.get('/s3/params', signOnServer)
+app.post('/s3/sign', signOnServer)
 
 //  === <S3 Multipart> ===
 // You can remove those endpoints if you only want to support the non-multipart uploads.
@@ -133,7 +138,9 @@ app.post('/s3/multipart', (req, res, next) => {
   const client = getS3Client()
   const { type, metadata, filename } = req.body
   if (typeof filename !== 'string') {
-    return res.status(400).json({ error: 's3: content filename must be a string' })
+    return res
+      .status(400)
+      .json({ error: 's3: content filename must be a string' })
   }
   if (typeof type !== 'string') {
     return res.status(400).json({ error: 's3: content type must be a string' })
@@ -154,7 +161,7 @@ app.post('/s3/multipart', (req, res, next) => {
       next(err)
       return
     }
-    res.setHeader('Access-Control-Allow-Origin', '*')
+    res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
     res.json({
       key: data.Key,
       uploadId: data.UploadId,
@@ -162,7 +169,7 @@ app.post('/s3/multipart', (req, res, next) => {
   })
 })
 
-function validatePartNumber (partNumber) {
+function validatePartNumber(partNumber) {
   // eslint-disable-next-line no-param-reassign
   partNumber = Number(partNumber)
   return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000
@@ -172,20 +179,33 @@ app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => {
   const { key } = req.query
 
   if (!validatePartNumber(partNumber)) {
-    return res.status(400).json({ error: 's3: the part number must be an integer between 1 and 10000.' })
+    return res
+      .status(400)
+      .json({
+        error: 's3: the part number must be an integer between 1 and 10000.',
+      })
   }
   if (typeof key !== 'string') {
-    return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    return res
+      .status(400)
+      .json({
+        error:
+          's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
+      })
   }
 
-  return getSignedUrl(getS3Client(), new UploadPartCommand({
-    Bucket: process.env.COMPANION_AWS_BUCKET,
-    Key: key,
-    UploadId: uploadId,
-    PartNumber: partNumber,
-    Body: '',
-  }), { expiresIn }).then((url) => {
-    res.setHeader('Access-Control-Allow-Origin', '*')
+  return getSignedUrl(
+    getS3Client(),
+    new UploadPartCommand({
+      Bucket: process.env.COMPANION_AWS_BUCKET,
+      Key: key,
+      UploadId: uploadId,
+      PartNumber: partNumber,
+      Body: '',
+    }),
+    { expiresIn },
+  ).then((url) => {
+    res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
     res.json({ url, expires: expiresIn })
   }, next)
 })
@@ -196,39 +216,52 @@ app.get('/s3/multipart/:uploadId', (req, res, next) => {
   const { key } = req.query
 
   if (typeof key !== 'string') {
-    res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    res
+      .status(400)
+      .json({
+        error:
+          's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
+      })
     return
   }
 
   const parts = []
 
-  function listPartsPage (startAt) {
-    client.send(new ListPartsCommand({
-      Bucket: process.env.COMPANION_AWS_BUCKET,
-      Key: key,
-      UploadId: uploadId,
-      PartNumberMarker: startAt,
-    }), (err, data) => {
-      if (err) {
-        next(err)
-        return
-      }
-
-      parts.push(...data.Parts)
-
-      if (data.IsTruncated) {
-        // Get the next page.
-        listPartsPage(data.NextPartNumberMarker)
-      } else {
-        res.json(parts)
-      }
-    })
+  function listPartsPage(startAt) {
+    client.send(
+      new ListPartsCommand({
+        Bucket: process.env.COMPANION_AWS_BUCKET,
+        Key: key,
+        UploadId: uploadId,
+        PartNumberMarker: startAt,
+      }),
+      (err, data) => {
+        if (err) {
+          next(err)
+          return
+        }
+
+        parts.push(...data.Parts)
+
+        if (data.IsTruncated) {
+          // Get the next page.
+          listPartsPage(data.NextPartNumberMarker)
+        } else {
+          res.json(parts)
+        }
+      },
+    )
   }
   listPartsPage(0)
 })
 
-function isValidPart (part) {
-  return part && typeof part === 'object' && Number(part.PartNumber) && typeof part.ETag === 'string'
+function isValidPart(part) {
+  return (
+    part &&
+    typeof part === 'object' &&
+    Number(part.PartNumber) &&
+    typeof part.ETag === 'string'
+  )
 }
 app.post('/s3/multipart/:uploadId/complete', (req, res, next) => {
   const client = getS3Client()
@@ -237,29 +270,41 @@ app.post('/s3/multipart/:uploadId/complete', (req, res, next) => {
   const { parts } = req.body
 
   if (typeof key !== 'string') {
-    return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    return res
+      .status(400)
+      .json({
+        error:
+          's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
+      })
   }
   if (!Array.isArray(parts) || !parts.every(isValidPart)) {
-    return res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' })
+    return res
+      .status(400)
+      .json({
+        error: 's3: `parts` must be an array of {ETag, PartNumber} objects.',
+      })
   }
 
-  return client.send(new CompleteMultipartUploadCommand({
-    Bucket: process.env.COMPANION_AWS_BUCKET,
-    Key: key,
-    UploadId: uploadId,
-    MultipartUpload: {
-      Parts: parts,
+  return client.send(
+    new CompleteMultipartUploadCommand({
+      Bucket: process.env.COMPANION_AWS_BUCKET,
+      Key: key,
+      UploadId: uploadId,
+      MultipartUpload: {
+        Parts: parts,
+      },
+    }),
+    (err, data) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
+      res.json({
+        location: data.Location,
+      })
     },
-  }), (err, data) => {
-    if (err) {
-      next(err)
-      return
-    }
-    res.setHeader('Access-Control-Allow-Origin', '*')
-    res.json({
-      location: data.Location,
-    })
-  })
+  )
 })
 
 app.delete('/s3/multipart/:uploadId', (req, res, next) => {
@@ -268,24 +313,89 @@ app.delete('/s3/multipart/:uploadId', (req, res, next) => {
   const { key } = req.query
 
   if (typeof key !== 'string') {
-    return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    return res
+      .status(400)
+      .json({
+        error:
+          's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
+      })
   }
 
-  return client.send(new AbortMultipartUploadCommand({
-    Bucket: process.env.COMPANION_AWS_BUCKET,
-    Key: key,
-    UploadId: uploadId,
-  }), (err) => {
-    if (err) {
-      next(err)
-      return
-    }
-    res.json({})
-  })
+  return client.send(
+    new AbortMultipartUploadCommand({
+      Bucket: process.env.COMPANION_AWS_BUCKET,
+      Key: key,
+      UploadId: uploadId,
+    }),
+    (err) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.json({})
+    },
+  )
 })
 
 // === </S3 MULTIPART> ===
 
+// === <some plumbing to make the example work> ===
+
+app.get('/', (req, res) => {
+  res.setHeader('Content-Type', 'text/html')
+  const htmlPath = path.join(__dirname, 'public', 'index.html')
+  res.sendFile(htmlPath)
+})
+app.get('/index.html', (req, res) => {
+  res.setHeader('Location', '/').sendStatus(308).end()
+})
+app.get('/withCustomEndpoints.html', (req, res) => {
+  res.setHeader('Content-Type', 'text/html')
+  const htmlPath = path.join(__dirname, 'public', 'withCustomEndpoints.html')
+  res.sendFile(htmlPath)
+})
+
+app.get('/uppy.min.mjs', (req, res) => {
+  res.setHeader('Content-Type', 'text/javascript')
+  const bundlePath = path.join(
+    __dirname,
+    '../..',
+    'packages/uppy/dist',
+    'uppy.min.mjs',
+  )
+  if (existsSync(bundlePath)) {
+    res.sendFile(bundlePath)
+  } else {
+    console.warn(
+      'No local JS bundle found, using the CDN as a fallback. Run `corepack yarn build` to make this warning disappear.',
+    )
+    res.end(
+      'export * from "https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs";\n',
+    )
+  }
+})
+app.get('/uppy.min.css', (req, res) => {
+  res.setHeader('Content-Type', 'text/css')
+  const bundlePath = path.join(
+    __dirname,
+    '../..',
+    'packages/uppy/dist',
+    'uppy.min.css',
+  )
+  if (existsSync(bundlePath)) {
+    res.sendFile(bundlePath)
+  } else {
+    console.warn(
+      'No local CSS bundle found, using the CDN as a fallback. Run `corepack yarn build` to make this warning disappear.',
+    )
+    res.end(
+      '@import "https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css";\n',
+    )
+  }
+})
+
 app.listen(port, () => {
-  console.log(`Example app listening on port ${port}`)
+  console.log(`Example app listening on port ${port}.`)
+  console.log(`Visit http://localhost:${port}/ on your browser to try it.`)
 })
+// === </some plumbing to make the example work> ===

+ 0 - 104
examples/aws-nodejs/public/drag.html

@@ -1,104 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8" />
-    <title>Uppy</title>
-    <link
-      href="https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css"
-      rel="stylesheet"
-    />
-  </head>
-  <body>
-    <section class="example">
-      <div id="drag-drop-area"></div>
-      <div class="for-ProgressBar"></div>
-      <div class="uploaded-files">
-        <h5>Uploaded files:</h5>
-        <ol></ol>
-      </div>
-      <script type="module">
-        import {
-          Uppy,
-          DragDrop,
-          ProgressBar,
-          AwsS3,
-        } from 'https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs'
-
-        // Function for displaying uploaded files
-        const onUploadSuccess = (elForUploadedFiles) => (file, response) => {
-          const url = response.uploadURL
-          const fileName = file.name
-
-          const li = document.createElement('li')
-          const a = document.createElement('a')
-          a.href = url
-          a.target = '_blank'
-          a.appendChild(document.createTextNode(fileName))
-          li.appendChild(a)
-
-          document.querySelector(elForUploadedFiles).appendChild(li)
-        }
-
-        var uppy = new Uppy({
-          autoProceed: true,
-          restrictions: {
-            maxNumberOfFiles: 10,
-          },
-        })
-          .use(DragDrop, {
-            inline: true,
-            target: '#drag-drop-area',
-          })
-          .use(ProgressBar, {
-            target: '.example .for-ProgressBar',
-            hideAfterFinish: true,
-          })
-          .use(AwsS3, {
-            getUploadParameters(file) {
-              // Send a request to our PHP signing endpoint.
-              return fetch('/sign-s3', {
-                method: 'post',
-                // Send and receive JSON.
-                headers: {
-                  accept: 'application/json',
-                  'content-type': 'application/json',
-                },
-                body: JSON.stringify({
-                  filename: file.name,
-                  contentType: file.type,
-                }),
-              })
-                .then((response) => {
-                  // Parse the JSON response.
-                  return response.json()
-                })
-                .then((data) => {
-                  // Return an object in the correct shape.
-                  return {
-                    method: data.method,
-                    url: data.url,
-                    fields: data.fields,
-                    // Provide content type header required by S3
-                    headers: {
-                      'Content-Type': file.type,
-                    },
-                  }
-                })
-            },
-          })
-
-        uppy.on('complete', (result) => {
-          console.log(
-            'Upload complete! We’ve uploaded these files:',
-            result.successful,
-          )
-        })
-
-        uppy.on(
-          'upload-success',
-          onUploadSuccess('.example .uploaded-files ol'),
-        )
-      </script>
-    </section>
-  </body>
-</html>

+ 46 - 242
examples/aws-nodejs/public/index.html

@@ -3,264 +3,68 @@
   <head>
     <meta charset="utf-8" />
     <title>Uppy – AWS upload example</title>
-    <link
-      href="https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css"
-      rel="stylesheet"
-    />
+    <link href="./uppy.min.css" rel="stylesheet" />
   </head>
   <body>
     <h1>AWS upload example</h1>
-    <div id="uppy"></div>
+    <details open name="uppy">
+      <summary>Sign on the server</summary>
+      <div id="uppy-sign-on-server"></div>
+    </details>
+    <details name="uppy">
+      <summary>Sign on the client (if WebCrypto is available)</summary>
+      <div id="uppy-sign-on-client"></div>
+    </details>
+    <footer>
+      You seeing the simplified example, with a backend that mimicks a
+      Companion-like instance. See
+      <a href="./withCustomEndpoints.html">the custom endpoint example</a> if
+      you need to see how to use one or more custom function for handling
+      communication with the backend.
+    </footer>
+    <noscript>You need JavaScript to run this example.</noscript>
     <script type="module">
-      import {
-        Uppy,
-        Dashboard,
-        AwsS3,
-      } from 'https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs'
-      /**
-       * This generator transforms a deep object into URL-encodable pairs
-       * to work with `URLSearchParams` on the client and `body-parser` on the server.
-       */
-      function* serializeSubPart(key, value) {
-        if (typeof value !== 'object') {
-          yield [key, value]
-          return
-        }
-        if (Array.isArray(value)) {
-          for (const val of value) {
-            yield* serializeSubPart(`${key}[]`, val)
-          }
-          return
-        }
-        for (const [subkey, val] of Object.entries(value)) {
-          yield* serializeSubPart(key ? `${key}[${subkey}]` : subkey, val)
-        }
+      import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs'
+      function onUploadComplete(result) {
+        console.log(
+          'Upload complete! We’ve uploaded these files:',
+          result.successful,
+        )
       }
-      function serialize(data) {
-        // If you want to avoid preflight requests, use URL-encoded syntax:
-        return new URLSearchParams(serializeSubPart(null, data))
-        // If you don't care about additional preflight requests, you can also use:
-        // return JSON.stringify(data)
-        // You'd also have to add `Content-Type` header with value `application/json`.
+      function onUploadSuccess(file, data) {
+        console.log(
+          'Upload success! We’ve uploaded this file:',
+          file.meta['name'],
+        )
       }
       {
-        const MiB = 0x10_00_00
-
         const uppy = new Uppy()
           .use(Dashboard, {
             inline: true,
-            target: '#uppy',
+            target: '#uppy-sign-on-server',
           })
           .use(AwsS3, {
             id: 'myAWSPlugin',
-
-            // Files that are more than 100MiB should be uploaded in multiple parts.
-            shouldUseMultipart: (file) => file.size > 100 * MiB,
-
-            /**
-             * This method tells Uppy how to retrieve a temporary token for signing on the client.
-             * Signing on the client is optional, you can also do the signing from the server.
-             */
-            async getTemporarySecurityCredentials({ signal }) {
-              const response = await fetch('/sts', { signal })
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-              return response.json()
-            },
-
-            // ========== Non-Multipart Uploads ==========
-
-            /**
-             * This method tells Uppy how to handle non-multipart uploads.
-             * If for some reason you want to only support multipart uploads,
-             * you don't need to implement it.
-             */
-            async getUploadParameters(file, options) {
-              if (typeof crypto?.subtle === 'object') {
-                // If WebCrypto is available, let's do signing from the client.
-                return uppy
-                  .getPlugin('myAWSPlugin')
-                  .createSignedURL(file, options)
-              }
-
-              // Send a request to our Express.js signing endpoint.
-              const response = await fetch('/sign-s3', {
-                method: 'POST',
-                headers: {
-                  accept: 'application/json',
-                },
-                body: serialize({
-                  filename: file.name,
-                  contentType: file.type,
-                }),
-                signal: options.signal,
-              })
-
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-
-              // Parse the JSON response.
-              const data = await response.json()
-
-              // Return an object in the correct shape.
-              return {
-                method: data.method,
-                url: data.url,
-                fields: {}, // For presigned PUT uploads, this should be left empty.
-                // Provide content type header required by S3
-                headers: {
-                  'Content-Type': file.type,
-                },
-              }
-            },
-
-            // ========== Multipart Uploads ==========
-
-            // The following methods are only useful for multipart uploads:
-            // If you are not interested in multipart uploads, you don't need to
-            // implement them (you'd also need to set `shouldUseMultipart: false` though).
-
-            async createMultipartUpload(file, signal) {
-              signal?.throwIfAborted()
-
-              const metadata = {}
-
-              Object.keys(file.meta || {}).forEach((key) => {
-                if (file.meta[key] != null) {
-                  metadata[key] = file.meta[key].toString()
-                }
-              })
-
-              const response = await fetch('/s3/multipart', {
-                method: 'POST',
-                // Send and receive JSON.
-                headers: {
-                  accept: 'application/json',
-                },
-                body: serialize({
-                  filename: file.name,
-                  type: file.type,
-                  metadata,
-                }),
-                signal,
-              })
-
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-
-              // Parse the JSON response.
-              const data = await response.json()
-
-              return data
-            },
-
-            async abortMultipartUpload(file, { key, uploadId, signal }) {
-              const filename = encodeURIComponent(key)
-              const uploadIdEnc = encodeURIComponent(uploadId)
-              const response = await fetch(
-                `/s3/multipart/${uploadIdEnc}?key=${filename}`,
-                {
-                  method: 'DELETE',
-                  signal,
-                },
-              )
-
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-            },
-
-            async signPart(file, options) {
-              if (typeof crypto?.subtle === 'object') {
-                // If WebCrypto, let's do signing from the client.
-                return uppy
-                  .getPlugin('myAWSPlugin')
-                  .createSignedURL(file, options)
-              }
-
-              const { uploadId, key, partNumber, signal } = options
-
-              signal?.throwIfAborted()
-
-              if (uploadId == null || key == null || partNumber == null) {
-                throw new Error(
-                  'Cannot sign without a key, an uploadId, and a partNumber',
-                )
-              }
-
-              const filename = encodeURIComponent(key)
-              const response = await fetch(
-                `/s3/multipart/${uploadId}/${partNumber}?key=${filename}`,
-                { signal },
-              )
-
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-
-              const data = await response.json()
-
-              return data
-            },
-
-            async listParts(file, { key, uploadId }, signal) {
-              signal?.throwIfAborted()
-
-              const filename = encodeURIComponent(key)
-              const response = await fetch(
-                `/s3/multipart/${uploadId}?key=${filename}`,
-                { signal },
-              )
-
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-
-              const data = await response.json()
-
-              return data
-            },
-
-            async completeMultipartUpload(
-              file,
-              { key, uploadId, parts },
-              signal,
-            ) {
-              signal?.throwIfAborted()
-
-              const filename = encodeURIComponent(key)
-              const uploadIdEnc = encodeURIComponent(uploadId)
-              const response = await fetch(
-                `s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
-                {
-                  method: 'POST',
-                  headers: {
-                    accept: 'application/json',
-                  },
-                  body: serialize({ parts }),
-                  signal,
-                },
-              )
-
-              if (!response.ok)
-                throw new Error('Unsuccessful request', { cause: response })
-
-              const data = await response.json()
-
-              return data
-            },
+            endpoint: '/',
           })
 
-        uppy.on('complete', (result) => {
-          console.log(
-            'Upload complete! We’ve uploaded these files:',
-            result.successful,
-          )
-        })
+        uppy.on('complete', onUploadComplete)
+        uppy.on('upload-success', onUploadSuccess)
+      }
+      {
+        const uppy = new Uppy()
+          .use(Dashboard, {
+            inline: true,
+            target: '#uppy-sign-on-client',
+          })
+          .use(AwsS3, {
+            id: 'myAWSPlugin',
+            endpoint: '/',
+            getTemporarySecurityCredentials: typeof crypto?.subtle === 'object',
+          })
 
-        uppy.on('upload-success', (file, data) => {
-          console.log(
-            'Upload success! We’ve uploaded this file:',
-            file.meta['name'],
-          )
-        })
+        uppy.on('complete', onUploadComplete)
+        uppy.on('upload-success', onUploadSuccess)
       }
     </script>
   </body>

+ 267 - 0
examples/aws-nodejs/public/withCustomEndpoints.html

@@ -0,0 +1,267 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <title>Uppy – AWS upload example</title>
+    <link href="./uppy.min.css" rel="stylesheet" />
+  </head>
+  <body>
+    <h1>AWS upload example</h1>
+    <div id="uppy"></div>
+    <footer>
+      You seeing the complex example, with a backend that does not mimick a
+      Companion-like instance. See
+      <a href="./index.html">the simplified example</a> if you don't need custom
+      functions for handling communication with the backend.
+    </footer>
+    <noscript>You need JavaScript to run this example.</noscript>
+    <script type="module">
+      import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs'
+      /**
+       * This generator transforms a deep object into URL-encodable pairs
+       * to work with `URLSearchParams` on the client and `body-parser` on the server.
+       */
+      function* serializeSubPart(key, value) {
+        if (typeof value !== 'object') {
+          yield [key, value]
+          return
+        }
+        if (Array.isArray(value)) {
+          for (const val of value) {
+            yield* serializeSubPart(`${key}[]`, val)
+          }
+          return
+        }
+        for (const [subkey, val] of Object.entries(value)) {
+          yield* serializeSubPart(key ? `${key}[${subkey}]` : subkey, val)
+        }
+      }
+      function serialize(data) {
+        // If you want to avoid preflight requests, use URL-encoded syntax:
+        return new URLSearchParams(serializeSubPart(null, data))
+        // If you don't care about additional preflight requests, you can also use:
+        // return JSON.stringify(data)
+        // You'd also have to add `Content-Type` header with value `application/json`.
+      }
+      {
+        const MiB = 0x10_00_00
+
+        const uppy = new Uppy()
+          .use(Dashboard, {
+            inline: true,
+            target: '#uppy',
+          })
+          .use(AwsS3, {
+            id: 'myAWSPlugin',
+
+            // Files that are more than 100MiB should be uploaded in multiple parts.
+            shouldUseMultipart: (file) => file.size > 100 * MiB,
+
+            /**
+             * This method tells Uppy how to retrieve a temporary token for signing on the client.
+             * Signing on the client is optional, you can also do the signing from the server.
+             */
+            async getTemporarySecurityCredentials({ signal }) {
+              const response = await fetch('/s3/sts', { signal })
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+              return response.json()
+            },
+
+            // ========== Non-Multipart Uploads ==========
+
+            /**
+             * This method tells Uppy how to handle non-multipart uploads.
+             * If for some reason you want to only support multipart uploads,
+             * you don't need to implement it.
+             */
+            async getUploadParameters(file, options) {
+              if (typeof crypto?.subtle === 'object') {
+                // If WebCrypto is available, let's do signing from the client.
+                return uppy
+                  .getPlugin('myAWSPlugin')
+                  .createSignedURL(file, options)
+              }
+
+              // Send a request to our Express.js signing endpoint.
+              const response = await fetch('/s3/sign', {
+                method: 'POST',
+                headers: {
+                  accept: 'application/json',
+                },
+                body: serialize({
+                  filename: file.name,
+                  contentType: file.type,
+                }),
+                signal: options.signal,
+              })
+
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+
+              // Parse the JSON response.
+              const data = await response.json()
+
+              // Return an object in the correct shape.
+              return {
+                method: data.method,
+                url: data.url,
+                fields: {}, // For presigned PUT uploads, this should be left empty.
+                // Provide content type header required by S3
+                headers: {
+                  'Content-Type': file.type,
+                },
+              }
+            },
+
+            // ========== Multipart Uploads ==========
+
+            // The following methods are only useful for multipart uploads:
+            // If you are not interested in multipart uploads, you don't need to
+            // implement them (you'd also need to set `shouldUseMultipart: false` though).
+
+            async createMultipartUpload(file, signal) {
+              signal?.throwIfAborted()
+
+              const metadata = {}
+
+              Object.keys(file.meta || {}).forEach((key) => {
+                if (file.meta[key] != null) {
+                  metadata[key] = file.meta[key].toString()
+                }
+              })
+
+              const response = await fetch('/s3/multipart', {
+                method: 'POST',
+                // Send and receive JSON.
+                headers: {
+                  accept: 'application/json',
+                },
+                body: serialize({
+                  filename: file.name,
+                  type: file.type,
+                  metadata,
+                }),
+                signal,
+              })
+
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+
+              // Parse the JSON response.
+              const data = await response.json()
+
+              return data
+            },
+
+            async abortMultipartUpload(file, { key, uploadId, signal }) {
+              const filename = encodeURIComponent(key)
+              const uploadIdEnc = encodeURIComponent(uploadId)
+              const response = await fetch(
+                `/s3/multipart/${uploadIdEnc}?key=${filename}`,
+                {
+                  method: 'DELETE',
+                  signal,
+                },
+              )
+
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+            },
+
+            async signPart(file, options) {
+              if (typeof crypto?.subtle === 'object') {
+                // If WebCrypto, let's do signing from the client.
+                return uppy
+                  .getPlugin('myAWSPlugin')
+                  .createSignedURL(file, options)
+              }
+
+              const { uploadId, key, partNumber, signal } = options
+
+              signal?.throwIfAborted()
+
+              if (uploadId == null || key == null || partNumber == null) {
+                throw new Error(
+                  'Cannot sign without a key, an uploadId, and a partNumber',
+                )
+              }
+
+              const filename = encodeURIComponent(key)
+              const response = await fetch(
+                `/s3/multipart/${uploadId}/${partNumber}?key=${filename}`,
+                { signal },
+              )
+
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+
+              const data = await response.json()
+
+              return data
+            },
+
+            async listParts(file, { key, uploadId }, signal) {
+              signal?.throwIfAborted()
+
+              const filename = encodeURIComponent(key)
+              const response = await fetch(
+                `/s3/multipart/${uploadId}?key=${filename}`,
+                { signal },
+              )
+
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+
+              const data = await response.json()
+
+              return data
+            },
+
+            async completeMultipartUpload(
+              file,
+              { key, uploadId, parts },
+              signal,
+            ) {
+              signal?.throwIfAborted()
+
+              const filename = encodeURIComponent(key)
+              const uploadIdEnc = encodeURIComponent(uploadId)
+              const response = await fetch(
+                `s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
+                {
+                  method: 'POST',
+                  headers: {
+                    accept: 'application/json',
+                  },
+                  body: serialize({ parts }),
+                  signal,
+                },
+              )
+
+              if (!response.ok)
+                throw new Error('Unsuccessful request', { cause: response })
+
+              const data = await response.json()
+
+              return data
+            },
+          })
+
+        uppy.on('complete', (result) => {
+          console.log(
+            'Upload complete! We’ve uploaded these files:',
+            result.successful,
+          )
+        })
+
+        uppy.on('upload-success', (file, data) => {
+          console.log(
+            'Upload success! We’ve uploaded this file:',
+            file.meta['name'],
+          )
+        })
+      }
+    </script>
+  </body>
+</html>

+ 7 - 5
packages/@uppy/aws-s3/src/index.test.ts

@@ -29,7 +29,7 @@ describe('AwsS3Multipart', () => {
       core.use(AwsS3Multipart)
       const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
 
-      const err = 'Expected a `companionUrl` option'
+      const err = 'Expected a `endpoint` option'
       const file = {}
       const opts = {}
 
@@ -330,8 +330,8 @@ describe('AwsS3Multipart', () => {
     beforeEach(() => {
       core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
-        companionUrl: '',
-        companionHeaders: {
+        endpoint: '',
+        headers: {
           authorization: oldToken,
         },
       })
@@ -340,7 +340,8 @@ describe('AwsS3Multipart', () => {
 
     it('companionHeader is updated before uploading file', async () => {
       awsS3Multipart.setOptions({
-        companionHeaders: {
+        endpoint: 'http://localhost',
+        headers: {
           authorization: newToken,
         },
       })
@@ -371,7 +372,8 @@ describe('AwsS3Multipart', () => {
           Body
         >
         awsS3Multipart.setOptions({
-          companionHeaders: {
+          endpoint: 'http://localhost',
+          headers: {
             authorization: newToken,
           },
         })

+ 71 - 19
packages/@uppy/aws-s3/src/index.ts

@@ -143,9 +143,15 @@ export interface AwsS3Part {
 }
 
 type AWSS3WithCompanion = {
-  companionUrl: string
-  companionHeaders?: Record<string, string>
-  companionCookiesRule?: string
+  endpoint: ConstructorParameters<
+    typeof RequestClient<any, any>
+  >[1]['companionUrl']
+  headers?: ConstructorParameters<
+    typeof RequestClient<any, any>
+  >[1]['companionHeaders']
+  cookiesRule?: ConstructorParameters<
+    typeof RequestClient<any, any>
+  >[1]['companionCookiesRule']
   getTemporarySecurityCredentials?: true
 }
 type AWSS3WithoutCompanion = {
@@ -253,11 +259,7 @@ type AWSS3MaybeMultipartWithoutCompanion<
     shouldUseMultipart: (file: UppyFile<M, B>) => boolean
   }
 
-type RequestClientOptions = Partial<
-  ConstructorParameters<typeof RequestClient<any, any>>[1]
->
-
-interface _AwsS3MultipartOptions extends PluginOpts, RequestClientOptions {
+interface _AwsS3MultipartOptions extends PluginOpts {
   allowedMetaFields?: string[] | boolean
   limit?: number
   retryDelays?: number[] | null
@@ -285,7 +287,6 @@ const defaultOptions = {
     // eslint-disable-next-line no-bitwise
     (file.size! >> 10) >> 10 > 100) as any as true,
   retryDelays: [0, 1000, 3000, 5000],
-  companionHeaders: {},
 } satisfies Partial<AwsS3MultipartOptions<any, any>>
 
 export default class AwsS3Multipart<
@@ -303,6 +304,7 @@ export default class AwsS3Multipart<
       | 'completeMultipartUpload'
     > &
     Required<Pick<AWSS3WithoutCompanion, 'uploadPartBytes'>> &
+    Partial<AWSS3WithCompanion> &
     AWSS3MultipartWithoutCompanionMandatorySignPart<M, B> &
     AWSS3NonMultipartWithoutCompanionMandatory<M, B>,
   M,
@@ -335,8 +337,7 @@ export default class AwsS3Multipart<
     // We need the `as any` here because of the dynamic default options.
     this.type = 'uploader'
     this.id = this.opts.id || 'AwsS3Multipart'
-    // TODO: only initiate `RequestClient` is `companionUrl` is defined.
-    this.#client = new RequestClient(uppy, (opts as any) ?? {})
+    this.#setClient(opts)
 
     const dynamicDefaultOptions = {
       createMultipartUpload: this.createMultipartUpload,
@@ -385,10 +386,59 @@ export default class AwsS3Multipart<
     return this.#client
   }
 
+  #setClient(opts?: Partial<AwsS3MultipartOptions<M, B>>) {
+    if (
+      opts == null ||
+      !(
+        'endpoint' in opts ||
+        'companionUrl' in opts ||
+        'headers' in opts ||
+        'companionHeaders' in opts ||
+        'cookiesRule' in opts ||
+        'companionCookiesRule' in opts
+      )
+    )
+      return
+    if ('companionUrl' in opts && !('endpoint' in opts)) {
+      this.uppy.log(
+        '`companionUrl` option has been removed in @uppy/aws-s3, use `endpoint` instead.',
+        'warning',
+      )
+    }
+    if ('companionHeaders' in opts && !('headers' in opts)) {
+      this.uppy.log(
+        '`companionHeaders` option has been removed in @uppy/aws-s3, use `headers` instead.',
+        'warning',
+      )
+    }
+    if ('companionCookiesRule' in opts && !('cookiesRule' in opts)) {
+      this.uppy.log(
+        '`companionCookiesRule` option has been removed in @uppy/aws-s3, use `cookiesRule` instead.',
+        'warning',
+      )
+    }
+    if ('endpoint' in opts) {
+      this.#client = new RequestClient(this.uppy, {
+        pluginId: this.id,
+        provider: 'AWS',
+        companionUrl: this.opts.endpoint!,
+        companionHeaders: this.opts.headers,
+        companionCookiesRule: this.opts.cookiesRule,
+      })
+    } else {
+      if ('headers' in opts) {
+        this.#setCompanionHeaders()
+      }
+      if ('cookiesRule' in opts) {
+        this.#client.opts.companionCookiesRule = opts.cookiesRule
+      }
+    }
+  }
+
   setOptions(newOptions: Partial<AwsS3MultipartOptions<M, B>>): void {
     this.#companionCommunicationQueue.setOptions(newOptions)
-    super.setOptions(newOptions)
-    this.#setCompanionHeaders()
+    super.setOptions(newOptions as any)
+    this.#setClient(newOptions)
   }
 
   /**
@@ -410,9 +460,9 @@ export default class AwsS3Multipart<
   }
 
   #assertHost(method: string): void {
-    if (!this.opts.companionUrl) {
+    if (!this.#client) {
       throw new Error(
-        `Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`,
+        `Expected a \`endpoint\` option containing a URL, or if you are not using Companion, a custom \`${method}\` implementation.`,
       )
     }
   }
@@ -486,15 +536,18 @@ export default class AwsS3Multipart<
     throwIfAborted(options?.signal)
 
     if (this.#cachedTemporaryCredentials == null) {
+      const { getTemporarySecurityCredentials } = this.opts
       // We do not await it just yet, so concurrent calls do not try to override it:
-      if (this.opts.getTemporarySecurityCredentials === true) {
+      if (getTemporarySecurityCredentials === true) {
         this.#assertHost('getTemporarySecurityCredentials')
         this.#cachedTemporaryCredentials = this.#client
           .get<AwsS3STSResponse>('s3/sts', options)
           .then(assertServerError)
       } else {
         this.#cachedTemporaryCredentials =
-          this.opts.getTemporarySecurityCredentials(options)
+          (getTemporarySecurityCredentials as AWSS3WithoutCompanion['getTemporarySecurityCredentials'])!(
+            options,
+          )
       }
       this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
       setTimeout(
@@ -572,7 +625,6 @@ export default class AwsS3Multipart<
   abortMultipartUpload(
     file: UppyFile<M, B>,
     { key, uploadId, signal }: UploadResultWithSignal,
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
   ): Promise<void> {
     this.#assertHost('abortMultipartUpload')
 
@@ -920,7 +972,7 @@ export default class AwsS3Multipart<
   }
 
   #setCompanionHeaders = () => {
-    this.#client.setCompanionHeaders(this.opts.companionHeaders)
+    this.#client?.setCompanionHeaders(this.opts.headers!)
   }
 
   #setResumableUploadsCapability = (boolean: boolean) => {