diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 22ea1f915e..91272bce34 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -47,6 +47,10 @@ yarn build
- `yarn typecheck` - Run TypeScript type checking
- `yarn check` - Run Biome linting and formatting
+### Running minio tests
+
+Tests for `@uppy/aws-s3` need a minio server running inside Docker, and in order to run those tests you must set the environment variable `VITE_MINIO_CONFIG="test_user,test_password,http://localhost:9002/test-bucket,us-east-1"` when running the tests.
+
### Headless components
When adding a new component to `@uppy/components`, you have to run `yarn migrate:components` from root
diff --git a/.github/workflows/bundlers.yml b/.github/workflows/bundlers.yml
index 0f2035da83..23bbf645e3 100644
--- a/.github/workflows/bundlers.yml
+++ b/.github/workflows/bundlers.yml
@@ -70,6 +70,12 @@ jobs:
mkdir /tmp/artifacts && corepack yarn workspaces foreach --all
--no-private pack --install-if-needed -o /tmp/artifacts/%s-${{
github.sha }}.tgz
+ - name: Debug packaged uppy dependencies
+ run: |
+ rm -rf /tmp/uppy-debug
+ mkdir -p /tmp/uppy-debug
+ tar -xzf /tmp/artifacts/uppy-${{ github.sha }}.tgz --strip-components 1 -C /tmp/uppy-debug
+ node -e 'const pkg=require("/tmp/uppy-debug/package.json");const fields=["dependencies","peerDependencies","optionalDependencies","devDependencies"];let found=false;for(const f of fields){if(!pkg[f])continue;for(const [k,v] of Object.entries(pkg[f])){if(String(v).startsWith("workspace:")){console.log(`${f}: ${k}=${v}`);found=true;}}}if(!found)console.log("No workspace:* entries in packaged uppy package.json");'
- name: Upload artifact
if: success()
uses: actions/upload-artifact@v6
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2d657c7ef1..e4c8bd1472 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,6 +59,7 @@ jobs:
COMPANION_DOMAIN: localhost:3020
COMPANION_PROTOCOL: http
COMPANION_REDIS_URL: redis://localhost:6379
+ VITE_MINIO_CONFIG: "test_user,test_password,http://localhost:9002/test-bucket,us-east-1"
types:
name: Types
diff --git a/examples/aws-companion/main.js b/examples/aws-companion/main.js
index 7c1dd7f432..55eb8e4502 100644
--- a/examples/aws-companion/main.js
+++ b/examples/aws-companion/main.js
@@ -23,5 +23,5 @@ uppy.use(Dashboard, {
plugins: ['GoogleDrive', 'Webcam'],
})
uppy.use(AwsS3, {
- endpoint: 'http://localhost:3020',
+ companionEndpoint: 'http://localhost:3020',
})
diff --git a/examples/aws-nodejs/README.md b/examples/aws-nodejs/README.md
index 1fc8c7fb54..96d7d11a49 100644
--- a/examples/aws-nodejs/README.md
+++ b/examples/aws-nodejs/README.md
@@ -1,7 +1,13 @@
# Uppy + AWS S3 with Node.JS
-A simple and fully working example of Uppy and AWS S3 storage with Node.js (and
-Express.js). It uses presigned URL at the backend level.
+A simple and fully working example of Uppy and AWS S3 storage with a Node.js
+(Express.js) backend. It demonstrates two signing modes:
+
+- **Client-side signing (STS)** — The server issues temporary credentials via
+ `GET /s3/sts`. The browser signs S3 requests locally using SigV4.
+- **Server-side signing (presigned URLs)** — The browser sends each S3 operation
+ to `POST /s3/presign`. The server generates a presigned URL; the browser uses
+ it directly.
## AWS Configuration
@@ -13,8 +19,8 @@ out of the scope here.
### S3 Setup
-Assuming you’re trying to setup the user `MY-UPPY-USER` to put the uploaded
-files to the bucket `MY-UPPY-BUCKET`, here’s how you can allow `MY-UPPY-USER` to
+Assuming you're trying to setup the user `MY-UPPY-USER` to put the uploaded
+files to the bucket `MY-UPPY-BUCKET`, here's how you can allow `MY-UPPY-USER` to
get STS Federated Token and upload files to `MY-UPPY-BUCKET`:
1. Set CORS settings on `MY-UPPY-BUCKET` bucket:
@@ -54,8 +60,8 @@ get STS Federated Token and upload files to `MY-UPPY-BUCKET`:
}
```
-3. Add the following Policy to `MY-UPPY-USER`: (if you don’t want to enable
- signing on the client, you can skip this step)
+3. Add the following Policy to `MY-UPPY-USER`: (required for client-side signing
+ via the STS endpoint)
```json
{
"Version": "2012-10-17",
@@ -100,9 +106,9 @@ COMPANION_AWS_SECRET=…
PORT=8080
```
-N.B.: This example uses `COMPANION_AWS_` environnement variables to facilitate
+N.B.: This example uses `COMPANION_AWS_` environment variables to facilitate
integrations with other examples in this repository, but this example does _not_
-uses Companion at all.
+use Companion at all.
## Enjoy it
diff --git a/examples/aws-nodejs/index.js b/examples/aws-nodejs/index.js
index 1010edf45f..b1dbb88f56 100644
--- a/examples/aws-nodejs/index.js
+++ b/examples/aws-nodejs/index.js
@@ -1,371 +1,39 @@
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')
+const bodyParser = require('body-parser')
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 {
- S3Client,
- AbortMultipartUploadCommand,
- CompleteMultipartUploadCommand,
- CreateMultipartUploadCommand,
- ListPartsCommand,
- PutObjectCommand,
- UploadPartCommand,
-} = require('@aws-sdk/client-s3')
-const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
-const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts')
-
-const policy = {
- Version: '2012-10-17',
- Statement: [
- {
- Effect: 'Allow',
- Action: ['s3:PutObject'],
- Resource: [
- `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`,
- `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`,
- ],
- },
- ],
-}
-
-/**
- * @type {S3Client}
- */
-let s3Client
-
-/**
- * @type {STSClient}
- */
-let stsClient
-
-const expiresIn = 900 // Define how long until a S3 signature expires.
-
-function getS3Client() {
- s3Client ??= new S3Client({
- region: process.env.COMPANION_AWS_REGION,
- credentials: {
- accessKeyId: process.env.COMPANION_AWS_KEY,
- secretAccessKey: process.env.COMPANION_AWS_SECRET,
- },
- forcePathStyle: process.env.COMPANION_AWS_FORCE_PATH_STYLE === 'true',
- })
- return s3Client
-}
-
-function getSTSClient() {
- stsClient ??= new STSClient({
- region: process.env.COMPANION_AWS_REGION,
- credentials: {
- accessKeyId: process.env.COMPANION_AWS_KEY,
- secretAccessKey: process.env.COMPANION_AWS_SECRET,
- },
- })
- return stsClient
-}
-
-// Generate a unique S3 key for the file
-const generateS3Key = (filename) => `${crypto.randomUUID()}-${filename}`
-
-// Extract the file parameters from the request
-const extractFileParameters = (req) => {
- const isPostRequest = req.method === 'POST'
- const params = isPostRequest ? req.body : req.query
-
- return {
- filename: params.filename,
- contentType: params.type,
- }
-}
-
-// Validate the file parameters
-const validateFileParameters = (filename, contentType) => {
- if (!filename || !contentType) {
- throw new Error(
- 'Missing required parameters: filename and content type are required',
- )
- }
-}
-
-app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json())
-
-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.
-
- const { filename, contentType } = extractFileParameters(req)
- validateFileParameters(filename, contentType)
-
- // Generate S3 key and prepare command
- const Key = generateS3Key(filename)
-
- 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)
-
-// === ===
-// You can remove those endpoints if you only want to support the non-multipart uploads.
-
-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' })
- }
- if (typeof type !== 'string') {
- return res.status(400).json({ error: 's3: content type must be a string' })
- }
- const Key = `${crypto.randomUUID()}-${filename}`
-
- const params = {
- Bucket: process.env.COMPANION_AWS_BUCKET,
- Key,
- ContentType: type,
- Metadata: metadata,
- }
-
- const command = new CreateMultipartUploadCommand(params)
- return client.send(command, (err, data) => {
- if (err) {
- next(err)
- return
- }
- res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
- res.json({
- key: data.Key,
- uploadId: data.UploadId,
- })
- })
-})
-
-function validatePartNumber(partNumber) {
- partNumber = Number(partNumber)
- return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000
-}
-app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => {
- const { uploadId, partNumber } = req.params
- 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.',
- })
- }
- 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 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)
-})
-
-app.get('/s3/multipart/:uploadId', (req, res, next) => {
- const client = getS3Client()
- const { uploadId } = req.params
- 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"',
- })
- return
- }
-
- const parts = []
-
- function listPartsPage(startsAt = undefined) {
- client.send(
- new ListPartsCommand({
- Bucket: process.env.COMPANION_AWS_BUCKET,
- Key: key,
- UploadId: uploadId,
- PartNumberMarker: startsAt,
- }),
- (err, data) => {
- if (err) {
- next(err)
- return
- }
-
- parts.push(...data.Parts)
-
- // continue to get list of all uploaded parts until the IsTruncated flag is false
- if (data.IsTruncated) {
- listPartsPage(data.NextPartNumberMarker)
- } else {
- res.json(parts)
- }
- },
- )
- }
- listPartsPage()
-})
+app.use(bodyParser.json())
-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()
- const { uploadId } = req.params
- const { key } = req.query
- const { parts } = req.body
+// --- S3 signing routes ---
+app.use(require('./routes/sts'))
+app.use(require('./routes/presign'))
- 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"',
- })
- }
- if (!Array.isArray(parts) || !parts.every(isValidPart)) {
- 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,
- },
- }),
- (err, data) => {
- if (err) {
- next(err)
- return
- }
- res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
- res.json({
- location: data.Location,
- })
- },
- )
-})
-
-app.delete('/s3/multipart/:uploadId', (req, res, next) => {
- const client = getS3Client()
- const { uploadId } = req.params
- 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 client.send(
- new AbortMultipartUploadCommand({
- Bucket: process.env.COMPANION_AWS_BUCKET,
- Key: key,
- UploadId: uploadId,
- }),
- (err) => {
- if (err) {
- next(err)
- return
- }
- res.json({})
- },
- )
-})
-
-// === ===
-
-// === ===
+// ---------------------------------------------------------------------------
+// Static file serving
+// ---------------------------------------------------------------------------
app.get('/', (req, res) => {
- res.setHeader('Content-Type', 'text/html')
const htmlPath = path.join(__dirname, 'public', 'index.html')
- res.sendFile(htmlPath)
+ require('node:fs').readFile(htmlPath, 'utf8', (err, html) => {
+ if (err) return res.status(500).send('Error loading page')
+ // Inject bucket/region config so the client can read them.
+ const config = ``
+ res.setHeader('Content-Type', 'text/html')
+ res.send(html.replace('', `${config}`))
+ })
})
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')
@@ -410,4 +78,3 @@ app.listen(port, () => {
console.log(`Example app listening on port ${port}.`)
console.log(`Visit http://localhost:${port}/ on your browser to try it.`)
})
-// === ===
diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html
index 881a07567a..1173b0c2cf 100644
--- a/examples/aws-nodejs/public/index.html
+++ b/examples/aws-nodejs/public/index.html
@@ -2,69 +2,117 @@
- Uppy – AWS upload example
+ Uppy – AWS S3 upload example
+
- AWS upload example
+ AWS S3 upload example
+ Demonstrates the @uppy/aws-s3 plugin with two signing modes.
+
- Sign on the server
-
+ Sign on the client (STS credentials)
+
+ Uses getCredentials to fetch temporary STS credentials from
+ /s3/sts. The browser signs all S3 requests locally using SigV4.
+
+
+
- Sign on the client (if WebCrypto is available)
-
+ Sign on the server (presigned URLs)
+
+ Uses signRequest to call /s3/presign for each S3
+ operation. The server generates presigned URLs; the browser uses them directly.
+
+
-
+
diff --git a/examples/aws-nodejs/public/withCustomEndpoints.html b/examples/aws-nodejs/public/withCustomEndpoints.html
deleted file mode 100644
index 2265a7bb70..0000000000
--- a/examples/aws-nodejs/public/withCustomEndpoints.html
+++ /dev/null
@@ -1,267 +0,0 @@
-
-
-
-
- Uppy – AWS upload example
-
-
-
- AWS upload example
-
-
-
-
-
-
diff --git a/examples/aws-nodejs/routes/presign.js b/examples/aws-nodejs/routes/presign.js
new file mode 100644
index 0000000000..a1f4638ac2
--- /dev/null
+++ b/examples/aws-nodejs/routes/presign.js
@@ -0,0 +1,108 @@
+/**
+ * POST /s3/presign — Presigned URLs for server-side signing (signRequest)
+ *
+ * The client sends the S3 operation details (method, key, uploadId, etc.)
+ * and this endpoint returns a presigned URL. The browser then sends the
+ * actual request directly to S3 using that URL.
+ */
+
+const { Router } = require('express')
+const {
+ S3Client,
+ AbortMultipartUploadCommand,
+ CompleteMultipartUploadCommand,
+ CreateMultipartUploadCommand,
+ ListPartsCommand,
+ PutObjectCommand,
+ UploadPartCommand,
+} = require('@aws-sdk/client-s3')
+const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
+
+const expiresIn = 900 // 15 minutes
+
+let s3Client
+function getS3Client() {
+ s3Client ??= new S3Client({
+ region: process.env.COMPANION_AWS_REGION,
+ credentials: {
+ accessKeyId: process.env.COMPANION_AWS_KEY,
+ secretAccessKey: process.env.COMPANION_AWS_SECRET,
+ },
+ forcePathStyle: process.env.COMPANION_AWS_FORCE_PATH_STYLE === 'true',
+ })
+ return s3Client
+}
+
+const router = Router()
+
+router.post('/s3/presign', async (req, res, next) => {
+ // Before giving the presigned URL to the client, you should first check if
+ // 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.
+
+ try {
+ const { method, key, uploadId, partNumber, contentType } = req.body
+ const client = getS3Client()
+ const bucket = process.env.COMPANION_AWS_BUCKET
+
+ if (!method || !key) {
+ return res.status(400).json({ error: 'method and key are required' })
+ }
+
+ let command
+
+ if (method === 'PUT' && uploadId && partNumber) {
+ // UploadPart (multipart)
+ command = new UploadPartCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId: uploadId,
+ PartNumber: parseInt(partNumber, 10),
+ })
+ } else if (method === 'PUT' && !uploadId && !partNumber) {
+ // PutObject (simple upload)
+ command = new PutObjectCommand({
+ Bucket: bucket,
+ Key: key,
+ ContentType: contentType || 'application/octet-stream',
+ })
+ } else if (method === 'POST' && !uploadId) {
+ // CreateMultipartUpload
+ command = new CreateMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ ContentType: contentType || 'application/octet-stream',
+ })
+ } else if (method === 'POST' && uploadId) {
+ // CompleteMultipartUpload
+ command = new CompleteMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId: uploadId,
+ })
+ } else if (method === 'DELETE' && uploadId) {
+ // AbortMultipartUpload
+ command = new AbortMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId: uploadId,
+ })
+ } else if (method === 'GET' && uploadId) {
+ // ListParts
+ command = new ListPartsCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId: uploadId,
+ })
+ } else {
+ return res.status(400).json({ error: 'Unsupported operation' })
+ }
+
+ const url = await getSignedUrl(client, command, { expiresIn })
+ res.json({ url })
+ } catch (err) {
+ next(err)
+ }
+})
+
+module.exports = router
diff --git a/examples/aws-nodejs/routes/sts.js b/examples/aws-nodejs/routes/sts.js
new file mode 100644
index 0000000000..b758eca636
--- /dev/null
+++ b/examples/aws-nodejs/routes/sts.js
@@ -0,0 +1,65 @@
+/**
+ * GET /s3/sts — Temporary credentials for client-side signing (getCredentials)
+ *
+ * Returns short-lived STS credentials so the browser can sign S3 requests
+ * locally using SigV4. The credentials are scoped to PutObject only.
+ */
+
+const { Router } = require('express')
+const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts')
+
+const expiresIn = 900 // 15 minutes
+
+// IAM policy for the federated user — allows PutObject to the bucket.
+const policy = {
+ Version: '2012-10-17',
+ Statement: [
+ {
+ Effect: 'Allow',
+ Action: ['s3:PutObject'],
+ Resource: [
+ `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`,
+ `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`,
+ ],
+ },
+ ],
+}
+
+let stsClient
+function getSTSClient() {
+ stsClient ??= new STSClient({
+ region: process.env.COMPANION_AWS_REGION,
+ credentials: {
+ accessKeyId: process.env.COMPANION_AWS_KEY,
+ secretAccessKey: process.env.COMPANION_AWS_SECRET,
+ },
+ })
+ return stsClient
+}
+
+const router = Router()
+
+router.get('/s3/sts', (req, res, next) => {
+ // Before giving the STS token to the client, you should first check if 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',
+ DurationSeconds: expiresIn,
+ Policy: JSON.stringify(policy),
+ }),
+ )
+ .then((response) => {
+ 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)
+})
+
+module.exports = router
diff --git a/examples/aws-php/main.js b/examples/aws-php/main.js
index 4704663bff..fbff325cb0 100644
--- a/examples/aws-php/main.js
+++ b/examples/aws-php/main.js
@@ -11,34 +11,24 @@ uppy.use(Dashboard, {
target: 'body',
})
uppy.use(AwsS3, {
- shouldUseMultipart: false, // The PHP backend only supports non-multipart uploads
+ // The PHP backend only signs single PutObject URLs (no multipart).
+ shouldUseMultipart: false,
- getUploadParameters(file) {
- // Send a request to our PHP signing endpoint.
- return fetch('/s3-sign.php', {
- method: 'post',
- // Send and receive JSON.
+ // signRequest is called for each S3 operation. For single PUTs the
+ // request is `{ method: 'PUT', key }`. The server returns a presigned URL.
+ signRequest: async (request) => {
+ const response = await fetch('/s3-sign.php', {
+ method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
- filename: file.name,
- contentType: file.type,
+ method: request.method,
+ key: request.key,
}),
})
- .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,
- headers: data.headers,
- }
- })
+ if (!response.ok) throw new Error('Failed to get presigned URL')
+ return response.json()
},
})
diff --git a/examples/aws-php/s3-sign.php b/examples/aws-php/s3-sign.php
index b6ea7b828f..6dae3d51c4 100644
--- a/examples/aws-php/s3-sign.php
+++ b/examples/aws-php/s3-sign.php
@@ -2,7 +2,14 @@
require 'vendor/autoload.php';
header('Access-Control-Allow-Origin: *');
-header("Access-Control-Allow-Headers: GET");
+header('Access-Control-Allow-Methods: POST, OPTIONS');
+header('Access-Control-Allow-Headers: Content-Type');
+
+// Short-circuit CORS preflight before any further processing.
+if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
+ http_response_code(204);
+ exit;
+}
// CONFIG: Change these variables to a valid region and bucket.
$awsEndpoint = getenv('COMPANION_AWS_ENDPOINT') ?: null;
@@ -11,36 +18,56 @@
// Directory to place uploaded files in.
$directory = 'uppy-php-example';
-// Create the S3 client.
-$s3 = new Aws\S3\S3Client([
+// Read credentials from the repo's Companion-style env vars (matches the
+// .env used by the rest of the examples). Fall back to the AWS SDK's default
+// credential chain (~/.aws/credentials, AWS_ACCESS_KEY_ID, etc.) if these
+// aren't set.
+$awsKey = getenv('COMPANION_AWS_KEY') ?: null;
+$awsSecret = getenv('COMPANION_AWS_SECRET') ?: null;
+
+$clientConfig = [
'version' => 'latest',
'endpoint' => $awsEndpoint,
'region' => $awsRegion,
-]);
+];
+if ($awsKey && $awsSecret) {
+ $clientConfig['credentials'] = [
+ 'key' => $awsKey,
+ 'secret' => $awsSecret,
+ ];
+}
-// Retrieve data about the file to be uploaded from the request body.
+$s3 = new Aws\S3\S3Client($clientConfig);
+
+// The @uppy/aws-s3 plugin sends `{ method, key }` per operation.
+// This example only handles PUT (PutObject) — multipart is disabled client-side.
$body = json_decode(file_get_contents('php://input'));
-$filename = $body->filename;
-$contentType = $body->contentType;
+if (!is_object($body)) {
+ http_response_code(400);
+ header('content-type: application/json');
+ echo json_encode(['error' => 'Invalid or empty JSON body']);
+ exit;
+}
+
+$method = $body->method ?? 'PUT';
+$key = $body->key ?? null;
+
+if ($method !== 'PUT' || !$key) {
+ http_response_code(400);
+ header('content-type: application/json');
+ echo json_encode(['error' => 'Only PUT requests with a key are supported']);
+ exit;
+}
-// Prepare a PutObject command.
$command = $s3->getCommand('putObject', [
'Bucket' => $bucket,
- 'Key' => "{$directory}/{$filename}",
- 'ContentType' => $contentType,
- 'Body' => '',
+ 'Key' => "{$directory}/{$key}",
]);
$request = $s3->createPresignedRequest($command, '+5 minutes');
header('content-type: application/json');
+// signRequest expects a `{ url }` response — no method/fields/headers.
echo json_encode([
- 'method' => $request->getMethod(),
'url' => (string) $request->getUri(),
- 'fields' => [],
- // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature.
- // Else, the browser picks a content-type as it sees fit.
- 'headers' => [
- 'content-type' => $contentType,
- ],
]);
diff --git a/examples/companion-digitalocean-spaces/README.md b/examples/companion-digitalocean-spaces/README.md
index a9b4a9cabb..f1cd992bc9 100644
--- a/examples/companion-digitalocean-spaces/README.md
+++ b/examples/companion-digitalocean-spaces/README.md
@@ -3,7 +3,7 @@
This example uses Uppy to upload files to a
[DigitalOcean Space](https://digitaloceanspaces.com/). DigitalOcean Spaces has
an identical API to S3, so we can use the
-[AwsS3](https://uppy.io/docs/aws-s3-multipart) plugin. We use @uppy/companion
+[AwsS3](https://uppy.io/docs/aws-s3) plugin. We use @uppy/companion
with a [custom `endpoint` configuration](./server.cjs#L39) that points to
DigitalOcean.
@@ -30,7 +30,7 @@ copy the `.env.example` file:
```
To setup the CORS settings of your Spaces bucket in accordance with
-[the plugin docs](https://uppy.io/docs/aws-s3-multipart/#setting-up-your-s3-bucket),
+[the plugin docs](https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket),
you can use the [example XML config file](./setcors.xml) with the
[`s3cmd` CLI](https://docs.digitalocean.com/products/spaces/reference/s3cmd/):
diff --git a/examples/companion-digitalocean-spaces/main.js b/examples/companion-digitalocean-spaces/main.js
index c1be7320c6..4510daaea9 100644
--- a/examples/companion-digitalocean-spaces/main.js
+++ b/examples/companion-digitalocean-spaces/main.js
@@ -15,4 +15,4 @@ uppy.use(Dashboard, {
})
// No client side changes needed!
-uppy.use(AwsS3, { companionUrl: '/companion' })
+uppy.use(AwsS3, { companionEndpoint: '/companion' })
diff --git a/packages/@uppy/aws-s3/package.json b/packages/@uppy/aws-s3/package.json
index 840997fad2..0cc3904d29 100644
--- a/packages/@uppy/aws-s3/package.json
+++ b/packages/@uppy/aws-s3/package.json
@@ -43,6 +43,7 @@
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.362.0",
+ "@aws-sdk/client-sts": "^3.362.0",
"@aws-sdk/s3-request-presigner": "^3.362.0",
"@uppy/core": "workspace:^",
"jsdom": "^26.1.0",
diff --git a/packages/@uppy/aws-s3/src/HTTPCommunicationQueue.ts b/packages/@uppy/aws-s3/src/HTTPCommunicationQueue.ts
deleted file mode 100644
index 43a30ab530..0000000000
--- a/packages/@uppy/aws-s3/src/HTTPCommunicationQueue.ts
+++ /dev/null
@@ -1,441 +0,0 @@
-import type { Body, Meta, UppyFile } from '@uppy/core'
-import type { RateLimitedQueue, WrapPromiseFunctionType } from '@uppy/utils'
-import type AwsS3Multipart from './index.js'
-import type {
- AwsS3MultipartOptions,
- AwsS3UploadParameters,
- uploadPartBytes,
-} from './index.js'
-import { type Chunk, pausingUploadReason } from './MultipartUploader.js'
-import type { UploadPartBytesResult, UploadResult } from './utils.js'
-import { throwIfAborted } from './utils.js'
-
-function removeMetadataFromURL(urlString: string) {
- const urlObject = new URL(urlString)
- urlObject.search = ''
- urlObject.hash = ''
- return urlObject.href
-}
-
-export class HTTPCommunicationQueue {
- #abortMultipartUpload!: WrapPromiseFunctionType<
- AwsS3Multipart['abortMultipartUpload']
- >
-
- #cache = new WeakMap()
-
- #createMultipartUpload!: WrapPromiseFunctionType<
- AwsS3Multipart['createMultipartUpload']
- >
-
- #fetchSignature!: WrapPromiseFunctionType['signPart']>
-
- #getUploadParameters!: WrapPromiseFunctionType<
- AwsS3Multipart['getUploadParameters']
- >
-
- #listParts!: WrapPromiseFunctionType['listParts']>
-
- #previousRetryDelay!: number
-
- #requests
-
- #retryDelays!: { values: () => Iterator }
-
- #sendCompletionRequest!: WrapPromiseFunctionType<
- AwsS3Multipart['completeMultipartUpload']
- >
-
- #setS3MultipartState
-
- #uploadPartBytes!: WrapPromiseFunctionType
-
- #getFile
-
- constructor(
- requests: RateLimitedQueue,
- options: AwsS3MultipartOptions,
- setS3MultipartState: (file: UppyFile, result: UploadResult) => void,
- getFile: (file: UppyFile) => UppyFile,
- ) {
- this.#requests = requests
- this.#setS3MultipartState = setS3MultipartState
- this.#getFile = getFile
- this.setOptions(options)
- }
-
- setOptions(options: Partial>): void {
- const requests = this.#requests
-
- if ('abortMultipartUpload' in options) {
- this.#abortMultipartUpload = requests.wrapPromiseFunction(
- options.abortMultipartUpload as any,
- { priority: 1 },
- )
- }
- if ('createMultipartUpload' in options) {
- this.#createMultipartUpload = requests.wrapPromiseFunction(
- options.createMultipartUpload as any,
- { priority: -1 },
- )
- }
- if ('signPart' in options) {
- this.#fetchSignature = requests.wrapPromiseFunction(
- options.signPart as any,
- )
- }
- if ('listParts' in options) {
- this.#listParts = requests.wrapPromiseFunction(options.listParts as any)
- }
- if ('completeMultipartUpload' in options) {
- this.#sendCompletionRequest = requests.wrapPromiseFunction(
- options.completeMultipartUpload as any,
- { priority: 1 },
- )
- }
- if ('retryDelays' in options) {
- this.#retryDelays = options.retryDelays ?? []
- }
- if ('uploadPartBytes' in options) {
- this.#uploadPartBytes = requests.wrapPromiseFunction(
- options.uploadPartBytes as any,
- { priority: Infinity },
- )
- }
- if ('getUploadParameters' in options) {
- this.#getUploadParameters = requests.wrapPromiseFunction(
- options.getUploadParameters as any,
- )
- }
- }
-
- async #shouldRetry(err: any, retryDelayIterator: Iterator) {
- const requests = this.#requests
- const status = err?.source?.status
-
- // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying,
- // perhaps the rate limited queue, and dedupe all plugins with that.
- if (status == null) {
- return false
- }
- if (status === 403 && err.message === 'Request has expired') {
- if (!requests.isPaused) {
- // We don't want to exhaust the retryDelayIterator as long as there are
- // more than one request in parallel, to give slower connection a chance
- // to catch up with the expiry set in Companion.
- if (requests.limit === 1 || this.#previousRetryDelay == null) {
- const next = retryDelayIterator.next()
- if (next == null || next.done) {
- return false
- }
- // If there are more than 1 request done in parallel, the RLQ limit is
- // decreased and the failed request is requeued after waiting for a bit.
- // If there is only one request in parallel, the limit can't be
- // decreased, so we iterate over `retryDelayIterator` as we do for
- // other failures.
- // `#previousRetryDelay` caches the value so we can re-use it next time.
- this.#previousRetryDelay = next.value
- }
- // No need to stop the other requests, we just want to lower the limit.
- requests.rateLimit(0)
- await new Promise((resolve) =>
- setTimeout(resolve, this.#previousRetryDelay),
- )
- }
- } else if (status === 429) {
- // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
- if (!requests.isPaused) {
- const next = retryDelayIterator.next()
- if (next == null || next.done) {
- return false
- }
- requests.rateLimit(next.value)
- }
- } else if (status > 400 && status < 500 && status !== 409) {
- // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
- return false
- } else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
- // The navigator is offline, let's wait for it to come back online.
- if (!requests.isPaused) {
- requests.pause()
- window.addEventListener(
- 'online',
- () => {
- requests.resume()
- },
- { once: true },
- )
- }
- } else {
- // Other error code means the request can be retried later.
- const next = retryDelayIterator.next()
- if (next == null || next.done) {
- return false
- }
- await new Promise((resolve) => setTimeout(resolve, next.value))
- }
- return true
- }
-
- async getUploadId(
- file: UppyFile,
- signal: AbortSignal,
- ): Promise {
- let cachedResult: Promise | UploadResult | undefined
- // As the cache is updated asynchronously, there could be a race condition
- // where we just miss a new result so we loop here until we get nothing back,
- // at which point it's out turn to create a new cache entry.
- for (;;) {
- if (file.data == null) throw new Error('File data is empty')
- cachedResult = this.#cache.get(file.data)
- if (cachedResult == null) break
- try {
- return await cachedResult
- } catch {
- // In case of failure, we want to ignore the cached error.
- // At this point, either there's a new cached value, or we'll exit the loop a create a new one.
- }
- }
-
- const promise = this.#createMultipartUpload(this.#getFile(file), signal)
-
- const abortPromise = () => {
- if (file.data == null) throw new Error('File data is empty')
- promise.abort(signal.reason)
- this.#cache.delete(file.data)
- }
- signal.addEventListener('abort', abortPromise, { once: true })
- this.#cache.set(file.data, promise)
- promise.then(
- async (result) => {
- signal.removeEventListener('abort', abortPromise)
- this.#setS3MultipartState(file, result)
- if (file.data == null) throw new Error('File data is empty')
- this.#cache.set(file.data, result)
- },
- () => {
- signal.removeEventListener('abort', abortPromise)
- if (file.data == null) throw new Error('File data is empty')
- this.#cache.delete(file.data)
- },
- )
-
- return promise
- }
-
- async abortFileUpload(file: UppyFile): Promise {
- if (file.data == null) throw new Error('File data is empty')
- const result = this.#cache.get(file.data)
- if (result == null) {
- // If the createMultipartUpload request never was made, we don't
- // need to send the abortMultipartUpload request.
- return
- }
- // Remove the cache entry right away for follow-up requests do not try to
- // use the soon-to-be aborted cached values.
- this.#cache.delete(file.data)
- this.#setS3MultipartState(file, Object.create(null))
- let awaitedResult: UploadResult
- try {
- awaitedResult = await result
- } catch {
- // If the cached result rejects, there's nothing to abort.
- return
- }
- await this.#abortMultipartUpload(this.#getFile(file), awaitedResult)
- }
-
- async #nonMultipartUpload(
- file: UppyFile,
- chunk: Chunk,
- signal?: AbortSignal,
- ) {
- const {
- method = 'POST',
- url,
- fields,
- headers,
- } = await this.#getUploadParameters(this.#getFile(file), {
- signal,
- }).abortOn(signal)
-
- let body: FormData | Blob
- const data = chunk.getData()
- if (method.toUpperCase() === 'POST') {
- const formData = new FormData()
- Object.entries(fields!).forEach(([key, value]) =>
- formData.set(key, value),
- )
- formData.set('file', data)
- body = formData
- } else {
- body = data
- }
-
- const { onProgress, onComplete } = chunk
-
- const result = (await this.#uploadPartBytes({
- signature: { url, headers, method } as any,
- body,
- size: data.size,
- onProgress,
- onComplete,
- signal,
- }).abortOn(signal)) as unknown as B // todo this doesn't make sense
-
- // Note: `fields.key` is not returned by old Companion versions.
- // See https://github.com/transloadit/uppy/pull/5602
- const key = fields?.key
- this.#setS3MultipartState(file, { key: key! })
-
- return {
- ...result,
- location:
- (result.location as string | undefined) ?? removeMetadataFromURL(url),
- bucket: fields?.bucket,
- key,
- }
- }
-
- async uploadFile(
- file: UppyFile,
- chunks: Chunk[],
- signal: AbortSignal,
- ): Promise> {
- throwIfAborted(signal)
- if (chunks.length === 1 && !chunks[0].shouldUseMultipart) {
- return this.#nonMultipartUpload(file, chunks[0], signal)
- }
- const { uploadId, key } = await this.getUploadId(file, signal)
- throwIfAborted(signal)
- try {
- const parts = await Promise.all(
- chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)),
- )
- throwIfAborted(signal)
- return await this.#sendCompletionRequest(
- this.#getFile(file),
- { key, uploadId, parts, signal },
- signal,
- ).abortOn(signal)
- } catch (err) {
- if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') {
- // We purposefully don't wait for the promise and ignore its status,
- // because we want the error `err` to bubble up ASAP to report it to the
- // user. A failure to abort is not that big of a deal anyway.
- this.abortFileUpload(file)
- }
- throw err
- }
- }
-
- restoreUploadFile(file: UppyFile, uploadIdAndKey: UploadResult): void {
- if (file.data == null) throw new Error('File data is empty')
- this.#cache.set(file.data, uploadIdAndKey)
- }
-
- async resumeUploadFile(
- file: UppyFile,
- chunks: Array,
- signal: AbortSignal,
- ): Promise {
- throwIfAborted(signal)
- if (
- chunks.length === 1 &&
- chunks[0] != null &&
- !chunks[0].shouldUseMultipart
- ) {
- return this.#nonMultipartUpload(file, chunks[0], signal)
- }
- const { uploadId, key } = await this.getUploadId(file, signal)
- throwIfAborted(signal)
- const alreadyUploadedParts = await this.#listParts(
- this.#getFile(file),
- { uploadId, key, signal },
- signal,
- ).abortOn(signal)
- throwIfAborted(signal)
- const parts = await Promise.all(
- chunks.map((chunk, i) => {
- const partNumber = i + 1
- const alreadyUploadedInfo = alreadyUploadedParts.find(
- ({ PartNumber }) => PartNumber === partNumber,
- )
- if (alreadyUploadedInfo == null) {
- return this.uploadChunk(file, partNumber, chunk!, signal)
- }
- // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded.
- chunk?.setAsUploaded?.()
- return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag }
- }),
- )
- throwIfAborted(signal)
- return this.#sendCompletionRequest(
- this.#getFile(file),
- { key, uploadId, parts, signal },
- signal,
- ).abortOn(signal)
- }
-
- async uploadChunk(
- file: UppyFile,
- partNumber: number,
- chunk: Chunk,
- signal: AbortSignal,
- ): Promise {
- throwIfAborted(signal)
- const { uploadId, key } = await this.getUploadId(file, signal)
-
- const signatureRetryIterator = this.#retryDelays.values()
- const chunkRetryIterator = this.#retryDelays.values()
- const shouldRetrySignature = () => {
- const next = signatureRetryIterator.next()
- if (next == null || next.done) {
- return null
- }
- return next.value
- }
-
- for (;;) {
- throwIfAborted(signal)
- const chunkData = chunk.getData()
- const { onProgress, onComplete } = chunk
- let signature: AwsS3UploadParameters
-
- try {
- signature = await this.#fetchSignature(this.#getFile(file), {
- // Always defined for multipart uploads
- uploadId: uploadId!,
- key,
- partNumber,
- body: chunkData,
- signal,
- }).abortOn(signal)
- } catch (err) {
- const timeout = shouldRetrySignature()
- if (timeout == null || signal.aborted) {
- throw err
- }
- await new Promise((resolve) => setTimeout(resolve, timeout))
- continue
- }
-
- throwIfAborted(signal)
- try {
- return {
- PartNumber: partNumber,
- ...(await this.#uploadPartBytes({
- signature,
- body: chunkData,
- size: chunkData.size,
- onProgress,
- onComplete,
- signal,
- }).abortOn(signal)),
- }
- } catch (err) {
- if (!(await this.#shouldRetry(err, chunkRetryIterator))) throw err
- }
- }
- }
-}
diff --git a/packages/@uppy/aws-s3/src/MultipartUploader.ts b/packages/@uppy/aws-s3/src/MultipartUploader.ts
deleted file mode 100644
index c2884f62b9..0000000000
--- a/packages/@uppy/aws-s3/src/MultipartUploader.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import type { Uppy } from '@uppy/core'
-import type { Body, Meta, UppyFile } from '@uppy/utils'
-import { AbortController } from '@uppy/utils'
-import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue.js'
-
-const MB = 1024 * 1024
-
-interface MultipartUploaderOptions {
- getChunkSize?: (file: { size: number }) => number
- onProgress?: (bytesUploaded: number, bytesTotal: number) => void
- onPartComplete?: (part: { PartNumber: number; ETag: string }) => void
- shouldUseMultipart?: boolean | ((file: UppyFile) => boolean)
- onSuccess?: (result: B) => void
- onError?: (err: unknown) => void
- companionComm: HTTPCommunicationQueue
- file: UppyFile
- log: Uppy['log']
-
- uploadId?: string
- key: string
-}
-
-const defaultOptions = {
- getChunkSize(file: { size: number }) {
- return Math.ceil(file.size / 10000)
- },
- onProgress() {},
- onPartComplete() {},
- onSuccess() {},
- onError(err: unknown) {
- throw err
- },
-} satisfies Partial>
-
-export interface Chunk {
- getData: () => Blob
- onProgress: (ev: ProgressEvent) => void
- onComplete: (etag: string) => void
- shouldUseMultipart: boolean
- setAsUploaded?: () => void
-}
-
-function ensureInt(value: T): T extends number | string ? number : never {
- if (typeof value === 'string') {
- // @ts-expect-error TS is not able to recognize it's fine.
- return parseInt(value, 10)
- }
- if (typeof value === 'number') {
- // @ts-expect-error TS is not able to recognize it's fine.
- return value
- }
- throw new TypeError('Expected a number')
-}
-
-export const pausingUploadReason = Symbol('pausing upload, not an actual error')
-
-/**
- * A MultipartUploader instance is used per file upload to determine whether a
- * upload should be done as multipart or as a regular S3 upload
- * (based on the user-provided `shouldUseMultipart` option value) and to manage
- * the chunk splitting.
- */
-class MultipartUploader {
- options: MultipartUploaderOptions &
- Required, keyof typeof defaultOptions>>
-
- #abortController = new AbortController()
-
- #chunks: Array = []
-
- #chunkState: { uploaded: number; etag?: string; done?: boolean }[] = []
-
- /**
- * The (un-chunked) data to upload.
- */
- #data: Blob
-
- #file: UppyFile
-
- #uploadHasStarted = false
-
- #onError: (err: unknown) => void
-
- #onSuccess: (result: B) => void
-
- #shouldUseMultipart: MultipartUploaderOptions['shouldUseMultipart']
-
- #isRestoring: boolean
-
- #onReject = (err: unknown) =>
- (err as any)?.cause === pausingUploadReason ? null : this.#onError(err)
-
- #maxMultipartParts = 10_000
-
- #minPartSize = 5 * MB
-
- constructor(data: Blob, options: MultipartUploaderOptions) {
- this.options = {
- ...defaultOptions,
- ...options,
- }
- // Use default `getChunkSize` if it was null or something
- this.options.getChunkSize ??= defaultOptions.getChunkSize
-
- this.#data = data
- this.#file = options.file
- this.#onSuccess = this.options.onSuccess
- this.#onError = this.options.onError
- this.#shouldUseMultipart = this.options.shouldUseMultipart
-
- // When we are restoring an upload, we already have an UploadId and a Key. Otherwise
- // we need to call `createMultipartUpload` to get an `uploadId` and a `key`.
- // Non-multipart uploads are not restorable.
- this.#isRestoring = (options.uploadId && options.key) as any as boolean
-
- this.#initChunks()
- }
-
- // initChunks checks the user preference for using multipart uploads (opts.shouldUseMultipart)
- // and calculates the optimal part size. When using multipart part uploads every part except for the last has
- // to be at least 5 MB and there can be no more than 10K parts.
- // This means we sometimes need to change the preferred part size from the user in order to meet these requirements.
- #initChunks() {
- const fileSize = this.#data.size
- const shouldUseMultipart =
- typeof this.#shouldUseMultipart === 'function'
- ? this.#shouldUseMultipart(this.#file)
- : Boolean(this.#shouldUseMultipart)
-
- if (shouldUseMultipart && fileSize > this.#minPartSize) {
- // At least 5MB per request:
- let chunkSize = Math.max(
- this.options.getChunkSize(this.#data) as number, // Math.max can take undefined but TS does not think so
- this.#minPartSize,
- )
- let arraySize = Math.floor(fileSize / chunkSize)
-
- // At most 10k requests per file:
- if (arraySize > this.#maxMultipartParts) {
- arraySize = this.#maxMultipartParts
- chunkSize = fileSize / this.#maxMultipartParts
- }
- this.#chunks = Array(arraySize)
-
- for (let offset = 0, j = 0; offset < fileSize; offset += chunkSize, j++) {
- const end = Math.min(fileSize, offset + chunkSize)
-
- // Defer data fetching/slicing until we actually need the data, because it's slow if we have a lot of files
- const getData = () => {
- const i2 = offset
- return this.#data.slice(i2, end)
- }
-
- this.#chunks[j] = {
- getData,
- onProgress: this.#onPartProgress(j),
- onComplete: this.#onPartComplete(j),
- shouldUseMultipart,
- }
- if (this.#isRestoring) {
- const size =
- offset + chunkSize > fileSize ? fileSize - offset : chunkSize
- // setAsUploaded is called by listPart, to keep up-to-date the
- // quantity of data that is left to actually upload.
- this.#chunks[j]!.setAsUploaded = () => {
- this.#chunks[j] = null
- this.#chunkState[j].uploaded = size
- }
- }
- }
- } else {
- this.#chunks = [
- {
- getData: () => this.#data,
- onProgress: this.#onPartProgress(0),
- onComplete: this.#onPartComplete(0),
- shouldUseMultipart,
- },
- ]
- }
-
- this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 }))
- }
-
- #createUpload() {
- this.options.companionComm
- .uploadFile(
- this.#file,
- this.#chunks as Chunk[],
- this.#abortController.signal,
- )
- .then(this.#onSuccess, this.#onReject)
- this.#uploadHasStarted = true
- }
-
- #resumeUpload() {
- this.options.companionComm
- .resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal)
- .then(this.#onSuccess, this.#onReject)
- }
-
- #onPartProgress = (index: number) => (ev: ProgressEvent) => {
- if (!ev.lengthComputable) return
-
- this.#chunkState[index].uploaded = ensureInt(ev.loaded)
-
- const totalUploaded = this.#chunkState.reduce((n, c) => n + c.uploaded, 0)
- this.options.onProgress(totalUploaded, this.#data.size)
- }
-
- #onPartComplete = (index: number) => (etag: string) => {
- // This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers.
- this.#chunks[index] = null
- this.#chunkState[index].etag = etag
- this.#chunkState[index].done = true
-
- const part = {
- PartNumber: index + 1,
- ETag: etag,
- }
- this.options.onPartComplete(part)
- }
-
- #abortUpload() {
- this.#abortController.abort()
- this.options.companionComm
- .abortFileUpload(this.#file)
- .catch((err: unknown) => this.options.log(err as Error))
- }
-
- start(): void {
- if (this.#uploadHasStarted) {
- if (!this.#abortController.signal.aborted)
- this.#abortController.abort(pausingUploadReason)
- this.#abortController = new AbortController()
- this.#resumeUpload()
- } else if (this.#isRestoring) {
- this.options.companionComm.restoreUploadFile(this.#file, {
- uploadId: this.options.uploadId,
- key: this.options.key,
- })
- this.#resumeUpload()
- } else {
- this.#createUpload()
- }
- }
-
- pause(): void {
- this.#abortController.abort(pausingUploadReason)
- // Swap it out for a new controller, because this instance may be resumed later.
- this.#abortController = new AbortController()
- }
-
- abort(opts?: { really?: boolean }): void {
- if (opts?.really) this.#abortUpload()
- else this.pause()
- }
-
- private [Symbol.for('uppy test: getChunkState')]() {
- return this.#chunkState
- }
-}
-
-export default MultipartUploader
diff --git a/packages/@uppy/aws-s3/src/S3Uploader.ts b/packages/@uppy/aws-s3/src/S3Uploader.ts
new file mode 100644
index 0000000000..fc6a80dc8a
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/S3Uploader.ts
@@ -0,0 +1,421 @@
+import { EventManager, type Uppy } from '@uppy/core'
+import type { Body, LocalUppyFile, Meta } from '@uppy/utils'
+import type S3Client from './s3-client/S3Client.js'
+
+/** Persisted S3 multipart state for Golden Retriever resume support */
+interface S3MultipartState {
+ uploadId: string
+ key: string
+}
+
+declare module '@uppy/utils' {
+ // biome-ignore lint/correctness/noUnusedVariables: must match existing interface signature
+ export interface LocalUppyFile {
+ s3Multipart?: S3MultipartState
+ }
+ // biome-ignore lint/correctness/noUnusedVariables: must match existing interface signature
+ export interface RemoteUppyFile {
+ s3Multipart?: S3MultipartState
+ }
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const MB = 1024 * 1024
+
+/** Minimum chunk size required by S3 (5MB) */
+const MIN_CHUNK_SIZE = 5 * MB
+
+/** Maximum number of parts allowed by S3 */
+const MAX_PARTS = 10000
+
+// ============================================================================
+// S3Uploader Types
+// ============================================================================
+
+interface S3UploaderOptions {
+ uppy: Uppy
+ s3Client: S3Client
+ file: LocalUppyFile
+ metadata: Record
+ key: string
+ shouldUseMultipart?: boolean
+ getChunkSize?: (file: { size: number }) => number
+ onProgress?: (bytesUploaded: number, bytesTotal: number) => void
+ onPartComplete?: (part: { PartNumber: number; ETag: string }) => void
+ onSuccess?: (result: UploadResult) => void
+ onError?: (err: Error) => void
+ onAbort?: () => void
+ log?: Uppy['log']
+}
+
+export interface UploadResult {
+ location: string
+ key: string
+ /** Only returned for multipart uploads */
+ uploadId?: string
+}
+
+interface Chunk {
+ index: number
+ start: number
+ end: number
+ size: number
+}
+
+interface ChunkState {
+ uploaded: number
+ etag?: string
+}
+
+export default class S3Uploader {
+ readonly #data: NonNullable['data']>
+ #key: string | undefined
+ readonly #options: S3UploaderOptions
+ readonly #eventManager: EventManager
+
+ #chunks: Chunk[] = []
+ #chunkState: ChunkState[] = []
+ #shouldUseMultipart: boolean = false
+ #uploadId?: string
+ #uploadHasStarted: boolean = false
+ #abortController: AbortController | undefined
+
+ constructor(options: S3UploaderOptions) {
+ if (options.file.data == null) {
+ throw new Error(`File data is missing for file ${options.file.id}`)
+ }
+ this.#options = options
+ this.#data = options.file.data
+ this.#eventManager = new EventManager(options.uppy)
+
+ // Detect resume state from file (persisted by Golden Retriever across page refreshes).
+ // Must run before #initChunks so it can force multipart mode for resumed uploads.
+ const resumeState = options.file.s3Multipart
+ if (resumeState) {
+ this.#key = resumeState.key
+ this.#uploadId = resumeState.uploadId
+ this.#uploadHasStarted = true
+ }
+
+ const fileSize = options.file.data.size
+
+ // Determine if we should use multipart
+ // If we're resuming a multipart upload, force multipart. Otherwise use
+ // the boolean option (true/false) and ensure the file is larger than
+ // S3's minimum chunk size when enabling multipart.
+ this.#shouldUseMultipart =
+ Boolean(resumeState) ||
+ (this.#options.shouldUseMultipart === true && fileSize > MIN_CHUNK_SIZE)
+
+ // Create chunks based on upload strategy
+ if (this.#shouldUseMultipart) {
+ // Calculate chunk size: at least MIN_CHUNK_SIZE, but may be larger for huge files
+ let chunkSize = this.#getChunkSize(fileSize)
+ chunkSize = Math.max(chunkSize, MIN_CHUNK_SIZE)
+
+ // Ensure we don't exceed MAX_PARTS (S3 limit: 10,000 parts)
+ if (Math.ceil(fileSize / chunkSize) > MAX_PARTS) {
+ chunkSize = Math.ceil(fileSize / MAX_PARTS)
+ }
+
+ // Create chunk definitions
+ for (
+ let offset = 0, index = 0;
+ offset < fileSize;
+ offset += chunkSize, index++
+ ) {
+ const end = Math.min(offset + chunkSize, fileSize)
+ this.#chunks.push({ index, start: offset, end, size: end - offset })
+ }
+ } else {
+ // Simple upload: single chunk for the entire file
+ this.#chunks = [{ index: 0, start: 0, end: fileSize, size: fileSize }]
+ }
+
+ this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 }))
+
+ // Setup events:
+ const fileId = this.#options.file.id
+
+ this.#eventManager.onFileRemove(fileId, () => {
+ this.abort()
+ this.#options.onAbort?.()
+ })
+
+ this.#eventManager.onCancelAll(fileId, () => {
+ this.abort()
+ this.#options.onAbort?.()
+ })
+
+ this.#eventManager.onFilePause(fileId, (isPaused) => {
+ if (isPaused) {
+ this.pause()
+ } else {
+ this.start()
+ }
+ })
+
+ this.#eventManager.onPauseAll(fileId, () => {
+ this.pause()
+ })
+
+ this.#eventManager.onResumeAll(fileId, () => {
+ this.start()
+ })
+
+ this.#eventManager.onRetry(fileId, () => {
+ this.start()
+ })
+
+ this.#eventManager.onRetryAll(fileId, () => {
+ this.start()
+ })
+ }
+
+ #getChunkSize(fileSize: number): number {
+ if (this.#options.getChunkSize) {
+ return this.#options.getChunkSize({ size: fileSize })
+ }
+ return Math.ceil(fileSize / MAX_PARTS)
+ }
+
+ async start(): Promise {
+ // Abort any pending operations (if not already aborted)
+ this.#abortController?.abort()
+ // Always create a fresh AbortController (also for resume)
+ this.#abortController = new AbortController()
+
+ try {
+ if (this.#uploadHasStarted) {
+ await this.#resumeUpload()
+ } else {
+ await this.#createUpload()
+ }
+ } catch (err) {
+ this.#onError(err as Error)
+ }
+ }
+
+ pause(): void {
+ this.#abortController?.abort()
+ }
+
+ /**
+ *
+ * @param opts - `abortInS3`: Whether to also abort the multipart upload in S3. Default: true. Set to false to keep the multipart upload in S3 active, allowing for manual cleanup later and preventing accidental data loss if the user later tries to resume the upload.
+ */
+ abort(opts?: { abortInS3?: boolean }): void {
+ this.#abortController?.abort()
+ // Clean up event listeners
+ this.#eventManager.remove()
+ if (opts?.abortInS3 !== false && this.#uploadId) {
+ if (!this.#key) {
+ throw new Error('Missing S3 object key for aborting upload')
+ }
+ this.#options.s3Client
+ .abortMultipartUpload(this.#key, this.#uploadId)
+ .catch((abortErr) => {
+ this.#options.log?.(abortErr, 'warning')
+ })
+ }
+ }
+
+ async #createUpload(): Promise {
+ this.#uploadHasStarted = true
+ if (this.#shouldUseMultipart) {
+ await this.#uploadMultipart()
+ } else {
+ await this.#uploadNonMultipart()
+ }
+ }
+
+ async #resumeUpload(): Promise {
+ if (!this.#uploadId) {
+ await this.#createUpload()
+ return
+ }
+ if (!this.#key) {
+ throw new Error('Missing S3 object key for resuming upload')
+ }
+ const existingParts = await this.#options.s3Client.listParts(
+ this.#uploadId,
+ this.#key,
+ )
+ // Sync local state with S3 - mark already-uploaded parts
+ for (const part of existingParts) {
+ const chunkIndex = part.partNumber - 1
+ if (chunkIndex >= 0 && chunkIndex < this.#chunkState.length) {
+ this.#chunkState[chunkIndex].uploaded = this.#chunks[chunkIndex].size
+ this.#chunkState[chunkIndex].etag = part.etag
+ }
+ }
+ // Emit progress update to reflect already-uploaded parts
+ this.#onProgress()
+ await this.#uploadRemainingParts()
+ }
+
+ async #uploadNonMultipart(): Promise {
+ const signal = this.#abortController?.signal
+ signal?.throwIfAborted()
+
+ const { location, key } = await this.#options.s3Client.putObject(
+ this.#options.key,
+ this.#data,
+ this.#options.file.type || 'application/octet-stream',
+ this.#options.metadata,
+ (bytesUploaded: number) => {
+ this.#chunkState[0].uploaded = bytesUploaded
+ this.#onProgress()
+ },
+ signal,
+ )
+
+ this.#onSuccess({
+ location,
+ key,
+ })
+ }
+
+ async #uploadMultipart(): Promise {
+ const signal = this.#abortController?.signal
+ signal?.throwIfAborted()
+
+ const { uploadId, key } =
+ await this.#options.s3Client.createMultipartUpload(
+ this.#options.key,
+ this.#options.file.type || 'application/octet-stream',
+ this.#options.metadata,
+ )
+ if (key == null) {
+ throw new Error(
+ 'S3 client did not return object key for multipart upload',
+ )
+ }
+
+ this.#key = key
+
+ // Persist resume state so Golden Retriever can restore it after page refresh
+ this.#options.uppy.setFileState(this.#options.file.id, {
+ s3Multipart: { uploadId, key },
+ })
+
+ this.#uploadId = uploadId
+
+ await this.#uploadRemainingParts()
+ }
+
+ async #uploadRemainingParts(): Promise {
+ const signal = this.#abortController?.signal
+
+ for (let i = 0; i < this.#chunks.length; i++) {
+ signal?.throwIfAborted()
+ if (this.#chunkState[i].etag) continue // already uploaded
+
+ const chunk = this.#chunks[i]
+ const partNumber = i + 1
+ const chunkData = this.#data.slice(chunk.start, chunk.end)
+ const chunkIndex = i // Capture for closure (cannot use for-loop variable i directly in a closure)
+
+ if (this.#key == null) {
+ throw new Error('Missing S3 object key for uploading part')
+ }
+ const { etag } = await this.#options.s3Client.uploadPart(
+ this.#key,
+ this.#uploadId!,
+ chunkData,
+ partNumber,
+ (bytesUploaded: number) => {
+ this.#chunkState[chunkIndex].uploaded = bytesUploaded
+ this.#onProgress()
+ },
+ signal,
+ )
+
+ // after part finished uploading, update chunk state
+ this.#chunkState[i].uploaded = chunk.size
+ this.#chunkState[i].etag = etag
+ this.#onProgress()
+
+ if (this.#options.onPartComplete) {
+ this.#options.onPartComplete({
+ PartNumber: partNumber,
+ ETag: etag,
+ })
+ }
+ }
+
+ signal?.throwIfAborted()
+
+ const parts = this.#chunkState.flatMap((state, i) =>
+ state.etag ? [{ partNumber: i + 1, etag: state.etag }] : [],
+ )
+
+ if (this.#key == null) {
+ throw new Error('Missing S3 object key for completing multipart upload')
+ }
+
+ const { location, key } =
+ await this.#options.s3Client.completeMultipartUpload(
+ this.#key,
+ this.#uploadId!,
+ parts,
+ )
+
+ this.#onSuccess({
+ location,
+ key,
+ uploadId: this.#uploadId,
+ })
+ }
+
+ #onProgress(): void {
+ if (!this.#options.onProgress) return
+ const bytesUploaded = this.#chunkState.reduce(
+ (sum, state) => sum + state.uploaded,
+ 0,
+ )
+ this.#options.onProgress(bytesUploaded, this.#data.size)
+ }
+
+ #onSuccess(result: UploadResult): void {
+ // If the upload was aborted (file removed mid-upload), the network request
+ // may still complete successfully. Don't emit success in this case since
+ // the file no longer exists in Uppy's state.
+ this.#eventManager.remove()
+ if (this.#abortController?.signal.aborted) {
+ return
+ }
+ // Clear persisted resume state — upload completed successfully.
+ this.#options.uppy.setFileState(this.#options.file.id, {
+ s3Multipart: undefined,
+ })
+ this.#options.onSuccess?.(result)
+ }
+
+ #onError(err: Error): void {
+ // ignore abort signals from intentional cancellation
+ if (err.name === 'AbortError') return
+ // If we intentionally aborted, don't report any subsequent errors
+ // (e.g., S3 returning 404 NoSuchUpload after we aborted the upload)
+ if (this.#abortController?.signal.aborted) return
+
+ // Clean up event listeners so this uploader doesn't become a "zombie"
+ // that reacts to future retry/pause/resume events after the error.
+ // Without this, each failed retry leaves an orphaned uploader that
+ // still listens for retry-all, causing duplicate uploads on the next
+ // successful retry.
+ this.#eventManager.remove()
+
+ // NOTE: We intentionally do NOT abort the multipart upload in S3 here.
+ // This allows the user to retry and resume from where they left off.
+ // The multipart upload is only aborted when the user cancels via the
+ // `abort()` method. By default `abort()` will also abort the multipart
+ // upload in S3 (abortInS3 = true). Pass { abortInS3: false } to keep the
+ // multipart upload in S3 so it can be cleaned up manually or resumed later.
+
+ this.#options.onError?.(err)
+ }
+}
diff --git a/packages/@uppy/aws-s3/src/createSignedURL.test.ts b/packages/@uppy/aws-s3/src/createSignedURL.test.ts
deleted file mode 100644
index f3a9e7a44c..0000000000
--- a/packages/@uppy/aws-s3/src/createSignedURL.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import assert from 'node:assert'
-import {
- PutObjectCommand,
- S3Client,
- UploadPartCommand,
-} from '@aws-sdk/client-s3'
-import { RequestChecksumCalculation } from '@aws-sdk/middleware-flexible-checksums'
-import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
-import { afterEach, beforeEach, describe, it } from 'vitest'
-import createSignedURL from './createSignedURL.js'
-
-const bucketName = 'some-bucket.with.dots'
-const s3ClientOptions = {
- region: 'us-bar-1',
- credentials: {
- accessKeyId: 'foo',
- secretAccessKey: 'bar',
- sessionToken: 'foobar',
- },
- // AWS SDK v3 started enabling request checksums by default, which changes
- // the presigned URL shape/signature. Keep tests aligned with our signer.
- requestChecksumCalculation: RequestChecksumCalculation.WHEN_REQUIRED,
-}
-const { Date: OriginalDate } = globalThis
-
-describe('createSignedURL', () => {
- beforeEach(() => {
- const now_ms = OriginalDate.now()
- // @ts-expect-error we're touching globals for test purposes.
- // biome-ignore lint/suspicious/noShadowRestrictedNames: ...
- globalThis.Date = function Date() {
- if (new.target) {
- return Reflect.construct(OriginalDate, [now_ms])
- }
- return Reflect.apply(OriginalDate, this, [now_ms])
- }
- globalThis.Date.now = function now() {
- return now_ms
- }
- })
- afterEach(() => {
- globalThis.Date = OriginalDate
- })
- it('should be able to sign non-multipart upload', async () => {
- const client = new S3Client(s3ClientOptions)
- assert.strictEqual(
- (
- await createSignedURL({
- accountKey: s3ClientOptions.credentials.accessKeyId,
- accountSecret: s3ClientOptions.credentials.secretAccessKey,
- sessionToken: s3ClientOptions.credentials.sessionToken,
- bucketName,
- Key: 'some/key',
- Region: s3ClientOptions.region,
- expires: 900,
- })
- ).searchParams.get('X-Amz-Signature'),
- new URL(
- await getSignedUrl(
- client,
- new PutObjectCommand({
- Bucket: bucketName,
- Key: 'some/key',
- }),
- { expiresIn: 900 },
- ),
- ).searchParams.get('X-Amz-Signature'),
- )
- })
- it('should be able to sign multipart upload', async () => {
- const client = new S3Client(s3ClientOptions)
- const partNumber = 99
- const uploadId = 'dummyUploadId'
- assert.strictEqual(
- (
- await createSignedURL({
- accountKey: s3ClientOptions.credentials.accessKeyId,
- accountSecret: s3ClientOptions.credentials.secretAccessKey,
- sessionToken: s3ClientOptions.credentials.sessionToken,
- uploadId,
- partNumber,
- bucketName,
- Key: 'some/key',
- Region: s3ClientOptions.region,
- expires: 900,
- })
- ).searchParams.get('X-Amz-Signature'),
- new URL(
- await getSignedUrl(
- client,
- new UploadPartCommand({
- Bucket: bucketName,
- UploadId: uploadId,
- PartNumber: partNumber,
- Key: 'some/key',
- }),
- { expiresIn: 900 },
- ),
- ).searchParams.get('X-Amz-Signature'),
- )
- })
-
- it('should escape path and query as restricted to RFC 3986', async () => {
- const client = new S3Client(s3ClientOptions)
- const partNumber = 99
- const specialChars = ";?:@&=+$,#!'()"
- const uploadId = `Upload${specialChars}Id`
- // '.*' chars of path should be encoded
- const Key = `${specialChars}.*/${specialChars}.*.ext`
- const implResult = await createSignedURL({
- accountKey: s3ClientOptions.credentials.accessKeyId,
- accountSecret: s3ClientOptions.credentials.secretAccessKey,
- sessionToken: s3ClientOptions.credentials.sessionToken,
- uploadId,
- partNumber,
- bucketName,
- Key,
- Region: s3ClientOptions.region,
- expires: 900,
- })
- const sdkResult = new URL(
- await getSignedUrl(
- client,
- new UploadPartCommand({
- Bucket: bucketName,
- UploadId: uploadId,
- PartNumber: partNumber,
- Key,
- }),
- { expiresIn: 900 },
- ),
- )
- assert.strictEqual(implResult.pathname, sdkResult.pathname)
-
- const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/
- const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/
- assert.strictEqual(
- implResult.search.match(extractUploadId)![2],
- sdkResult.search.match(extractUploadId)![2],
- )
- assert.strictEqual(
- implResult.search.match(extractSignature)![2],
- sdkResult.search.match(extractSignature)![2],
- )
- })
-})
diff --git a/packages/@uppy/aws-s3/src/createSignedURL.ts b/packages/@uppy/aws-s3/src/createSignedURL.ts
deleted file mode 100644
index a10fcb800b..0000000000
--- a/packages/@uppy/aws-s3/src/createSignedURL.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * Create a canonical request by concatenating the following strings, separated
- * by newline characters. This helps ensure that the signature that you
- * calculate and the signature that AWS calculates can match.
- *
- * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request
- *
- * @param param0
- * @param param0.method – The HTTP method.
- * @param param0.CanonicalUri – The URI-encoded version of the absolute
- * path component URL (everything between the host and the question mark
- * character (?) that starts the query string parameters). If the absolute path
- * is empty, use a forward slash character (/).
- * @param param0.CanonicalQueryString – The URL-encoded query string
- * parameters, separated by ampersands (&). Percent-encode reserved characters,
- * including the space character. Encode names and values separately. If there
- * are empty parameters, append the equals sign to the parameter name before
- * encoding. After encoding, sort the parameters alphabetically by key name. If
- * there is no query string, use an empty string ("").
- * @param param0.SignedHeaders – The request headers,
- * that will be signed, and their values, separated by newline characters.
- * For the values, trim any leading or trailing spaces, convert sequential
- * spaces to a single space, and separate the values for a multi-value header
- * using commas. You must include the host header (HTTP/1.1), and any x-amz-*
- * headers in the signature. You can optionally include other standard headers
- * in the signature, such as content-type.
- * @param param0.HashedPayload – A string created using the payload in
- * the body of the HTTP request as input to a hash function. This string uses
- * lowercase hexadecimal characters. If the payload is empty, use an empty
- * string as the input to the hash function.
- */
-function createCanonicalRequest({
- method = 'PUT',
- CanonicalUri = '/',
- CanonicalQueryString = '',
- SignedHeaders,
- HashedPayload,
-}: {
- method?: string
- CanonicalUri: string
- CanonicalQueryString: string
- SignedHeaders: Record
- HashedPayload: string
-}): string {
- const headerKeys = Object.keys(SignedHeaders)
- .map((k) => k.toLowerCase())
- .sort()
- return [
- method,
- CanonicalUri,
- CanonicalQueryString,
- ...headerKeys.map((k) => `${k}:${SignedHeaders[k]}`),
- '',
- headerKeys.join(';'),
- HashedPayload,
- ].join('\n')
-}
-
-const ec = new TextEncoder()
-const algorithm = { name: 'HMAC', hash: 'SHA-256' }
-
-async function digest(data: string): ReturnType {
- const { subtle } = globalThis.crypto
- return subtle.digest(algorithm.hash, ec.encode(data))
-}
-
-async function generateHmacKey(secret: string | ArrayBuffer) {
- const { subtle } = globalThis.crypto
- return subtle.importKey(
- 'raw',
- typeof secret === 'string' ? ec.encode(secret) : secret,
- algorithm,
- false,
- ['sign'],
- )
-}
-
-function arrayBufferToHexString(arrayBuffer: ArrayBuffer) {
- const byteArray = new Uint8Array(arrayBuffer)
- let hexString = ''
- for (let i = 0; i < byteArray.length; i++) {
- hexString += byteArray[i].toString(16).padStart(2, '0')
- }
- return hexString
-}
-
-async function hash(key: Parameters[0], data: string) {
- const { subtle } = globalThis.crypto
- return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data))
-}
-
-/**
- * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
- */
-export default async function createSignedURL({
- accountKey,
- accountSecret,
- sessionToken,
- bucketName,
- Key,
- Region,
- expires,
- uploadId,
- partNumber,
-}: {
- accountKey: string
- accountSecret: string
- sessionToken: string
- bucketName: string
- Key: string
- Region: string
- expires: string | number
- uploadId?: string
- partNumber?: string | number
-}): Promise {
- const Service = 's3'
- const host = `${Service}.${Region}.amazonaws.com`
- /**
- * List of char out of `encodeURI()` is taken from ECMAScript spec.
- * Note that the `/` character is purposefully not included in list below.
- *
- * @see https://tc39.es/ecma262/#sec-encodeuri-uri
- */
- const CanonicalUri = `/${bucketName}/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`
- const payload = 'UNSIGNED-PAYLOAD'
-
- const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ
- const date = requestDateTime.slice(0, 8) // YYYYMMDD
- const scope = `${date}/${Region}/${Service}/aws4_request`
-
- const url = new URL(`https://${host}${CanonicalUri}`)
- // N.B.: URL search params needs to be added in the ASCII order
- url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256')
- url.searchParams.set('X-Amz-Content-Sha256', payload)
- url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`)
- url.searchParams.set('X-Amz-Date', requestDateTime)
- url.searchParams.set('X-Amz-Expires', expires as string)
- // We are signing on the client, so we expect there's going to be a session token:
- url.searchParams.set('X-Amz-Security-Token', sessionToken)
- url.searchParams.set('X-Amz-SignedHeaders', 'host')
- // Those two are present only for Multipart Uploads:
- if (partNumber) url.searchParams.set('partNumber', partNumber as string)
- if (uploadId) url.searchParams.set('uploadId', uploadId)
- url.searchParams.set(
- 'x-id',
- partNumber && uploadId ? 'UploadPart' : 'PutObject',
- )
-
- // Step 1: Create a canonical request
- const canonical = createCanonicalRequest({
- CanonicalUri,
- CanonicalQueryString: url.search.slice(1),
- SignedHeaders: {
- host,
- },
- HashedPayload: payload,
- })
-
- // Step 2: Create a hash of the canonical request
- const hashedCanonical = arrayBufferToHexString(await digest(canonical))
-
- // Step 3: Create a string to sign
- const stringToSign = [
- `AWS4-HMAC-SHA256`, // The algorithm used to create the hash of the canonical request.
- requestDateTime, // The date and time used in the credential scope.
- scope, // The credential scope. This restricts the resulting signature to the specified Region and service.
- hashedCanonical, // The hash of the canonical request.
- ].join('\n')
-
- // Step 4: Calculate the signature
- const kDate = await hash(`AWS4${accountSecret}`, date)
- const kRegion = await hash(kDate, Region)
- const kService = await hash(kRegion, Service)
- const kSigning = await hash(kService, 'aws4_request')
- const signature = arrayBufferToHexString(await hash(kSigning, stringToSign))
-
- // Step 5: Add the signature to the request
- url.searchParams.set('X-Amz-Signature', signature)
-
- return url
-}
diff --git a/packages/@uppy/aws-s3/src/index.test.ts b/packages/@uppy/aws-s3/src/index.test.ts
deleted file mode 100644
index f3061f79a8..0000000000
--- a/packages/@uppy/aws-s3/src/index.test.ts
+++ /dev/null
@@ -1,794 +0,0 @@
-import {
- afterEach,
- beforeEach,
- describe,
- expect,
- it,
- type Mock,
- vi,
-} from 'vitest'
-
-import 'whatwg-fetch'
-import Core, { type Meta, type UppyFile } from '@uppy/core'
-import nock from 'nock'
-import AwsS3Multipart, {
- type AwsBody,
- type AwsS3MultipartOptions,
-} from './index.js'
-
-const KB = 1024
-const MB = KB * KB
-
-describe('AwsS3Multipart', () => {
- beforeEach(() => nock.disableNetConnect())
-
- it('Registers AwsS3Multipart upload plugin', () => {
- const core = new Core().use(AwsS3Multipart)
-
- // @ts-expect-error private property
- const pluginNames = core[Symbol.for('uppy test: getPlugins')](
- 'uploader',
- ).map((plugin: AwsS3Multipart) => plugin.constructor.name)
- expect(pluginNames).toContain('AwsS3Multipart')
- })
-
- describe('defaultOptions', () => {
- let opts: Partial>
-
- beforeEach(() => {
- const core = new Core().use(AwsS3Multipart)
- const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
- opts = awsS3Multipart.opts
- })
-
- it('allowedMetaFields is true', () => {
- expect(opts.allowedMetaFields).toBe(true)
- })
-
- it('limit is 6', () => {
- expect(opts.limit).toBe(6)
- })
-
- it('getTemporarySecurityCredentials is false', () => {
- expect(opts.getTemporarySecurityCredentials).toBe(false)
- })
-
- describe('shouldUseMultipart', () => {
- const MULTIPART_THRESHOLD = 100 * MB
-
- let shouldUseMultipart: (file: UppyFile) => boolean
-
- beforeEach(() => {
- shouldUseMultipart = opts.shouldUseMultipart as (
- file: UppyFile,
- ) => boolean
- })
-
- const createFile = (size: number): UppyFile => ({
- name: '',
- size,
- data: new Blob(),
- extension: '',
- id: '',
- isRemote: false,
- isGhost: false,
- meta: undefined,
- progress: {
- percentage: 0,
- bytesUploaded: 0,
- bytesTotal: size,
- uploadComplete: false,
- uploadStarted: 0,
- },
- type: '',
- })
-
- it('returns true for files larger than 100MB', () => {
- const file = createFile(MULTIPART_THRESHOLD + 1)
- expect(shouldUseMultipart(file)).toBe(true)
- })
-
- it('returns false for files exactly 100MB', () => {
- const file = createFile(MULTIPART_THRESHOLD)
- expect(shouldUseMultipart(file)).toBe(false)
- })
-
- it('returns false for files smaller than 100MB', () => {
- const file = createFile(MULTIPART_THRESHOLD - 1)
- expect(shouldUseMultipart(file)).toBe(false)
- })
-
- it('returns true for large files (~70GB)', () => {
- const file = createFile(70 * 1024 * MB)
- expect(shouldUseMultipart(file)).toBe(true)
- })
-
- it('returns true for very large files (~400GB)', () => {
- const file = createFile(400 * 1024 * MB)
- expect(shouldUseMultipart(file)).toBe(true)
- })
-
- it('returns false for files with size 0', () => {
- const file = createFile(0)
- expect(shouldUseMultipart(file)).toBe(false)
- })
- })
-
- it('retryDelays is [0, 1000, 3000, 5000]', () => {
- expect(opts.retryDelays).toEqual([0, 1000, 3000, 5000])
- })
- })
-
- describe('companionUrl assertion', () => {
- it('Throws an error for main functions if configured without companionUrl', () => {
- const core = new Core().use(AwsS3Multipart)
- const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
-
- const err = 'Expected a `endpoint` option'
- const file = {} as unknown as UppyFile>
- const opts = {} as any
-
- expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err)
- expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err)
- expect(() =>
- awsS3Multipart.opts.completeMultipartUpload(file, opts),
- ).toThrow(err)
- expect(() =>
- awsS3Multipart.opts.abortMultipartUpload(file, opts),
- ).toThrow(err)
- expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err)
- })
- })
-
- describe('non-multipart upload', () => {
- it('should handle POST uploads', async () => {
- const core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: false,
- limit: 0,
- getUploadParameters: () => ({
- method: 'POST',
- url: 'https://bucket.s3.us-east-2.amazonaws.com/',
- fields: {
- key: 'file',
- bucket: 'https://bucket.s3.us-east-2.amazonaws.com/',
- },
- }),
- })
- const scope = nock(
- 'https://bucket.s3.us-east-2.amazonaws.com',
- ).defaultReplyHeaders({
- 'access-control-allow-headers': '*',
- 'access-control-allow-method': 'POST',
- 'access-control-allow-origin': '*',
- 'access-control-expose-headers': 'ETag, Location',
- })
-
- scope.options('/').reply(204, '')
- scope
- .post('/')
- .reply(201, '', { ETag: 'test', Location: 'http://example.com' })
-
- const fileSize = 1
-
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- const uploadSuccessHandler = vi.fn()
- core.on('upload-success', uploadSuccessHandler)
-
- await core.upload()
-
- expect(uploadSuccessHandler.mock.calls).toHaveLength(1)
- expect(uploadSuccessHandler.mock.calls[0][1]).toStrictEqual({
- body: {
- ETag: 'test',
- etag: 'test',
- location: 'http://example.com',
- key: 'file',
- bucket: 'https://bucket.s3.us-east-2.amazonaws.com/',
- },
- status: 200,
- uploadURL: 'http://example.com',
- })
-
- scope.done()
- })
- })
-
- describe('without companionUrl (custom main functions)', () => {
- let core: Core
- let awsS3Multipart: AwsS3Multipart
-
- beforeEach(() => {
- core = new Core()
- core.use(AwsS3Multipart, {
- shouldUseMultipart: true,
- limit: 0,
- createMultipartUpload: vi.fn(() => {
- return {
- uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
- key: 'test/upload/multitest.dat',
- }
- }),
- completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
- abortMultipartUpload: vi.fn(),
- signPart: vi.fn(async (file, { number }) => {
- return {
- url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
- headers: number === 1 ? { 'Content-MD5': 'foo' } : undefined,
- }
- }),
- listParts: undefined as any,
- })
- awsS3Multipart = core.getPlugin('AwsS3Multipart')!
- })
-
- it('Keeps chunks marked as busy through retries until they complete', async () => {
- const scope = nock(
- 'https://bucket.s3.us-east-2.amazonaws.com',
- ).defaultReplyHeaders({
- 'access-control-allow-headers': '*',
- 'access-control-allow-method': 'PUT',
- 'access-control-allow-origin': '*',
- 'access-control-expose-headers': 'ETag',
- })
-
- const fileSize = 50 * MB
-
- scope
- .options((uri) => uri.includes('test/upload/multitest.dat'))
- .reply(200, '')
- scope
- .put(
- (uri) =>
- uri.includes('test/upload/multitest.dat') &&
- !uri.includes('partNumber=7'),
- )
- .reply(200, '', { ETag: 'test' })
-
- // Fail the part 7 upload once, then let it succeed
- let calls = 0
- scope
- .put(
- (uri) =>
- uri.includes('test/upload/multitest.dat') &&
- uri.includes('partNumber=7'),
- )
- .reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }]))
-
- scope.persist()
-
- // Spy on the busy/done state of the test chunk (part 7, chunk index 6)
- let busySpy: Mock
- let doneSpy: Mock
- awsS3Multipart.setOptions({
- shouldUseMultipart: true,
- retryDelays: [10],
- createMultipartUpload: vi.fn((file) => {
- // @ts-expect-error protected property
- const multipartUploader = awsS3Multipart.uploaders[file.id]!
- const testChunkState =
- // @ts-expect-error private method
- multipartUploader[Symbol.for('uppy test: getChunkState')]()[6]
- let busy = false
- let done = false
- busySpy = vi.fn((value) => {
- busy = value
- })
- doneSpy = vi.fn((value) => {
- done = value
- })
- Object.defineProperty(testChunkState, 'busy', {
- get: () => busy,
- set: busySpy,
- })
- Object.defineProperty(testChunkState, 'done', {
- get: () => done,
- set: doneSpy,
- })
-
- return {
- uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
- key: 'test/upload/multitest.dat',
- }
- }),
- })
-
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- await core.upload()
-
- // The chunk should be marked as done once
- expect(doneSpy!.mock.calls.length).toEqual(1)
- expect(doneSpy!.mock.calls[0][0]).toEqual(true)
-
- // Any changes that set busy to false should only happen after the chunk has been marked done,
- // otherwise a race condition occurs (see PR #3955)
- const doneCallOrderNumber = doneSpy!.mock.invocationCallOrder[0]
- for (const [index, callArgs] of busySpy!.mock.calls.entries()) {
- if (callArgs[0] === false) {
- expect(busySpy!.mock.invocationCallOrder[index]).toBeGreaterThan(
- doneCallOrderNumber,
- )
- }
- }
-
- expect((awsS3Multipart.opts as any).signPart.mock.calls.length).toEqual(
- 10,
- )
- })
- })
-
- describe('MultipartUploader', () => {
- const createMultipartUpload = vi.fn(() => {
- return {
- uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
- key: 'test/upload/multitest.dat',
- }
- })
-
- const signPart = vi.fn(async (file, { partNumber }) => {
- return {
- url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/${file.name}?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
- }
- })
-
- const uploadPartBytes = vi.fn()
-
- afterEach(() => {
- vi.clearAllMocks()
- })
-
- it('retries uploadPartBytes when it fails once', async () => {
- const core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: true,
- createMultipartUpload,
- completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
- abortMultipartUpload: vi.fn(() => {
- throw 'should ignore'
- }),
- signPart,
- uploadPartBytes: uploadPartBytes.mockImplementationOnce(() =>
- Promise.reject({ source: { status: 500 } }),
- ),
- listParts: undefined as any,
- })
- const fileSize = 5 * MB + 1 * MB
-
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- await core.upload()
-
- expect(uploadPartBytes.mock.calls.length).toEqual(3)
- })
-
- it('calls `upload-error` when uploadPartBytes fails after all retries', async () => {
- const core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: true,
- retryDelays: [10],
- createMultipartUpload: vi.fn((file) => ({
- uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
- key: `test/upload/${file.name}`,
- })),
- completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
- abortMultipartUpload: vi.fn(),
- signPart,
- uploadPartBytes: uploadPartBytes.mockImplementation((options) => {
- if (options.signature.url.includes('succeed.dat')) {
- return new Promise((resolve) => {
- // delay until after multitest.dat has failed.
- setTimeout(() => resolve({ status: 200 }), 100)
- })
- }
- return Promise.reject({ source: { status: 500 } })
- }),
- listParts: undefined as any,
- })
- const fileSize = 5 * MB + 1 * MB
- const uploadErrorMock = vi.fn()
- const uploadSuccessMock = vi.fn()
- core.on('upload-error', uploadErrorMock)
- core.on('upload-success', uploadSuccessMock)
-
- core.addFile({
- source: 'vi',
- name: 'fail.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- core.addFile({
- source: 'vi',
- name: 'succeed.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- try {
- const results = await core.upload()
- expect(results!.successful!.length).toEqual(1)
- expect(results!.failed!.length).toEqual(1)
- } catch {
- // Catch Promise.all reject
- }
-
- expect(uploadPartBytes.mock.calls.length).toEqual(5)
- expect(uploadErrorMock.mock.calls.length).toEqual(1)
- expect(uploadSuccessMock.mock.calls.length).toEqual(1) // This fails for me becuase upload returned early.
- })
-
- it('retries signPart when it fails', async () => {
- // The retry logic for signPart happens in the uploadChunk method of HTTPCommunicationQueue
- // For a 6MB file, we expect 2 parts, so signPart should be called for each part
- let callCount = 0
- const signPartWithRetry = vi.fn((file, { partNumber }) => {
- callCount++
- if (callCount === 1) {
- // First call fails with a retryable error
- throw { source: { status: 500 } }
- }
- return {
- url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992`,
- }
- })
-
- const core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: true,
- retryDelays: [10],
- createMultipartUpload: vi.fn(() => ({
- uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
- key: 'test/upload/multitest.dat',
- })),
- completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
- abortMultipartUpload: vi.fn(),
- signPart: signPartWithRetry,
- uploadPartBytes: vi.fn().mockResolvedValue({ status: 200 }),
- listParts: undefined as any,
- })
- const fileSize = 5 * MB + 1 * MB
-
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- await core.upload()
-
- // Should be called 3 times: 1 failed + 1 retry + 1 for second part
- expect(signPartWithRetry).toHaveBeenCalledTimes(3)
- })
- })
-
- describe('dynamic companionHeader', () => {
- let core: Core
- let awsS3Multipart: AwsS3Multipart
- const oldToken = 'old token'
- const newToken = 'new token'
-
- beforeEach(() => {
- core = new Core()
- core.use(AwsS3Multipart, {
- endpoint: '',
- headers: {
- authorization: oldToken,
- },
- })
- awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
- })
-
- it('companionHeader is updated before uploading file', async () => {
- awsS3Multipart.setOptions({
- endpoint: 'http://localhost',
- headers: {
- authorization: newToken,
- },
- })
-
- await core.upload()
-
- // @ts-expect-error private property
- const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
-
- expect(
- client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
- ).toEqual(newToken)
- })
- })
-
- describe('dynamic companionHeader using setOption', () => {
- let core: Core
- let awsS3Multipart: AwsS3Multipart
- const newToken = 'new token'
-
- it('companionHeader is updated before uploading file', async () => {
- core = new Core()
- core.use(AwsS3Multipart)
- /* Set up preprocessor */
- core.addPreProcessor(() => {
- awsS3Multipart =
- core.getPlugin>('AwsS3Multipart')!
- awsS3Multipart.setOptions({
- endpoint: 'http://localhost',
- headers: {
- authorization: newToken,
- },
- })
- })
-
- await core.upload()
-
- // @ts-expect-error private property
- const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
-
- expect(
- client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
- ).toEqual(newToken)
- })
- })
-
- describe('file metadata across custom main functions', () => {
- let core: Core
- const createMultipartUpload = vi.fn((file) => {
- core.setFileMeta(file.id, {
- ...file.meta,
- createMultipartUpload: true,
- })
-
- return {
- uploadId: 'upload1234',
- key: file.name,
- }
- })
-
- const signPart = vi.fn((file, partData) => {
- expect(file.meta.createMultipartUpload).toBe(true)
-
- core.setFileMeta(file.id, {
- ...file.meta,
- signPart: true,
- [`part${partData.partNumber}`]: partData.partNumber,
- })
-
- return {
- url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
- }
- })
-
- const listParts = vi.fn((file) => {
- expect(file.meta.createMultipartUpload).toBe(true)
- core.setFileMeta(file.id, {
- ...file.meta,
- listParts: true,
- })
-
- const partKeys = Object.keys(file.meta).filter((metaKey) =>
- metaKey.startsWith('part'),
- )
- return partKeys.map((metaKey) => ({
- PartNumber: file.meta[metaKey],
- ETag: metaKey,
- Size: 5 * MB,
- }))
- })
-
- const completeMultipartUpload = vi.fn((file) => {
- expect(file.meta.createMultipartUpload).toBe(true)
- expect(file.meta.signPart).toBe(true)
- for (let i = 1; i <= 10; i++) {
- expect(file.meta[`part${i}`]).toBe(i)
- }
- return {}
- })
-
- const abortMultipartUpload = vi.fn((file) => {
- expect(file.meta.createMultipartUpload).toBe(true)
- expect(file.meta.signPart).toBe(true)
- expect(file.meta.abortingPart).toBe(5)
- })
-
- beforeEach(() => {
- createMultipartUpload.mockClear()
- signPart.mockClear()
- listParts.mockClear()
- abortMultipartUpload.mockClear()
- completeMultipartUpload.mockClear()
- })
-
- it('preserves file metadata if upload is completed', async () => {
- core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: true,
- createMultipartUpload,
- signPart,
- listParts,
- completeMultipartUpload,
- abortMultipartUpload,
- })
-
- nock('https://bucket.s3.us-east-2.amazonaws.com')
- .defaultReplyHeaders({
- 'access-control-allow-headers': '*',
- 'access-control-allow-method': 'PUT',
- 'access-control-allow-origin': '*',
- 'access-control-expose-headers': 'ETag',
- })
- .put((uri) => uri.includes('test/upload/multitest.dat'))
- .reply(200, '', { ETag: 'test' })
- .persist()
-
- const fileSize = 50 * MB
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- await core.upload()
- expect(createMultipartUpload).toHaveBeenCalled()
- expect(signPart).toHaveBeenCalledTimes(11)
- expect(completeMultipartUpload).toHaveBeenCalled()
- })
-
- it('preserves file metadata if upload is aborted', async () => {
- const signPartWithAbort = vi.fn((file, partData) => {
- expect(file.meta.createMultipartUpload).toBe(true)
- if (partData.partNumber === 5) {
- core.setFileMeta(file.id, {
- ...file.meta,
- abortingPart: partData.partNumber,
- })
- core.removeFile(file.id)
- return {
- url: undefined as any as string,
- }
- }
-
- core.setFileMeta(file.id, {
- ...file.meta,
- signPart: true,
- [`part${partData.partNumber}`]: partData.partNumber,
- })
-
- return {
- url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
- }
- })
-
- core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: true,
- createMultipartUpload,
- signPart: signPartWithAbort,
- listParts,
- completeMultipartUpload,
- abortMultipartUpload,
- })
-
- nock('https://bucket.s3.us-east-2.amazonaws.com')
- .defaultReplyHeaders({
- 'access-control-allow-headers': '*',
- 'access-control-allow-method': 'PUT',
- 'access-control-allow-origin': '*',
- 'access-control-expose-headers': 'ETag',
- })
- .put((uri) => uri.includes('test/upload/multitest.dat'))
- .reply(200, '', { ETag: 'test' })
- .persist()
-
- const fileSize = 50 * MB
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- await core.upload()
- expect(createMultipartUpload).toHaveBeenCalled()
- expect(signPartWithAbort).toHaveBeenCalled()
- expect(abortMultipartUpload).toHaveBeenCalled()
- })
-
- it('preserves file metadata if upload is paused and resumed', async () => {
- const completeMultipartUploadAfterPause = vi.fn((file) => {
- expect(file.meta.createMultipartUpload).toBe(true)
- expect(file.meta.signPart).toBe(true)
- for (let i = 1; i <= 10; i++) {
- expect(file.meta[`part${i}`]).toBe(i)
- }
-
- expect(file.meta.listParts).toBe(true)
- return {}
- })
-
- const signPartWithPause = vi.fn((file, partData) => {
- expect(file.meta.createMultipartUpload).toBe(true)
- if (partData.partNumber === 3) {
- core.setFileMeta(file.id, {
- ...file.meta,
- abortingPart: partData.partNumber,
- })
- core.pauseResume(file.id)
- setTimeout(() => core.pauseResume(file.id), 500)
- }
-
- core.setFileMeta(file.id, {
- ...file.meta,
- signPart: true,
- [`part${partData.partNumber}`]: partData.partNumber,
- })
-
- return {
- url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
- }
- })
-
- core = new Core().use(AwsS3Multipart, {
- shouldUseMultipart: true,
- createMultipartUpload,
- signPart: signPartWithPause,
- listParts,
- completeMultipartUpload: completeMultipartUploadAfterPause,
- abortMultipartUpload,
- })
-
- nock('https://bucket.s3.us-east-2.amazonaws.com')
- .defaultReplyHeaders({
- 'access-control-allow-headers': '*',
- 'access-control-allow-method': 'PUT',
- 'access-control-allow-origin': '*',
- 'access-control-expose-headers': 'ETag',
- })
- .put((uri) => uri.includes('test/upload/multitest.dat'))
- .reply(200, '', { ETag: 'test' })
- .persist()
-
- const fileSize = 50 * MB
- core.addFile({
- source: 'vi',
- name: 'multitest.dat',
- type: 'application/octet-stream',
- data: new File([new Uint8Array(fileSize)], '', {
- type: 'application/octet-stream',
- }),
- })
-
- await core.upload()
- expect(createMultipartUpload).toHaveBeenCalled()
- expect(signPartWithPause).toHaveBeenCalled()
- expect(listParts).toHaveBeenCalled()
- expect(completeMultipartUploadAfterPause).toHaveBeenCalled()
- })
- })
-})
diff --git a/packages/@uppy/aws-s3/src/index.ts b/packages/@uppy/aws-s3/src/index.ts
index d248f4c5be..7f0dbd511d 100644
--- a/packages/@uppy/aws-s3/src/index.ts
+++ b/packages/@uppy/aws-s3/src/index.ts
@@ -1,8 +1,7 @@
-import { RequestClient } from '@uppy/companion-client'
+import type { RequestClient } from '@uppy/companion-client'
import {
BasePlugin,
type DefinePluginOpts,
- EventManager,
type PluginOpts,
type Uppy,
} from '@uppy/core'
@@ -10,30 +9,31 @@ import type {
Body,
LocalUppyFile,
Meta,
- RequestOptions,
+ RemoteUppyFile,
UppyFile,
} from '@uppy/utils'
import {
- createAbortError,
filterFilesToEmitUploadStarted,
filterFilesToUpload,
getAllowedMetaFields,
- RateLimitedQueue,
+ TaskQueue,
} from '@uppy/utils'
import packageJson from '../package.json' with { type: 'json' }
-import createSignedURL from './createSignedURL.js'
-import { HTTPCommunicationQueue } from './HTTPCommunicationQueue.js'
-import MultipartUploader from './MultipartUploader.js'
-import type {
- MultipartUploadResultWithSignal,
- UploadPartBytesResult,
- UploadResult,
- UploadResultWithSignal,
-} from './utils.js'
-import { throwIfAborted } from './utils.js'
+import S3Uploader, { type UploadResult } from './S3Uploader.js'
+import S3Companion from './s3-client/CompanionS3.js'
+import type S3Client from './s3-client/S3Client.js'
+import S3mini from './s3-client/S3mini.js'
+import type * as IT from './s3-client/types.js'
+
+// ============================================================================
+// Types
+// ============================================================================
-type MultipartFile = UppyFile & {
- s3Multipart: UploadResult
+/** Part information for multipart uploads */
+export interface AwsS3Part {
+ PartNumber?: number
+ Size?: number
+ ETag?: string
}
type PartUploadedCallback = (
@@ -45,983 +45,352 @@ declare module '@uppy/core' {
export interface UppyEventMap {
's3-multipart:part-uploaded': PartUploadedCallback
}
- export interface PluginTypeRegistry {
- AwsS3Multipart: AwsS3Multipart
- }
-}
-
-function assertServerError(res: T): T {
- if ((res as any)?.error) {
- const error = new Error((res as any).message)
- Object.assign(error, (res as any).error)
- throw error
- }
- return res
-}
-
-export interface AwsS3STSResponse {
- credentials: {
- AccessKeyId: string
- SecretAccessKey: string
- SessionToken: string
- Expiration?: string
- }
- bucket: string
- region: string
-}
-
-/**
- * Computes the expiry time for a request signed with temporary credentials. If
- * no expiration was provided, or an invalid value (e.g. in the past) is
- * provided, undefined is returned. This function assumes the client clock is in
- * sync with the remote server, which is a requirement for the signature to be
- * validated for AWS anyway.
- */
-function getExpiry(
- credentials: AwsS3STSResponse['credentials'],
-): number | undefined {
- const expirationDate = credentials.Expiration
- if (expirationDate) {
- const timeUntilExpiry = Math.floor(
- ((new Date(expirationDate) as any as number) - Date.now()) / 1000,
- )
- if (timeUntilExpiry > 9) {
- return timeUntilExpiry
- }
- }
- return undefined
-}
-
-function getAllowedMetadata>({
- meta,
- allowedMetaFields,
- querify = false,
-}: {
- meta: M
- allowedMetaFields?: string[] | null
- querify?: boolean
-}) {
- const metaFields = allowedMetaFields ?? Object.keys(meta)
-
- if (!meta) return {}
-
- return Object.fromEntries(
- metaFields
- .filter((key) => meta[key] != null)
- .map((key) => {
- const realKey = querify ? `metadata[${key}]` : key
- const value = String(meta[key])
- return [realKey, value]
- }),
- )
-}
-
-type MaybePromise = T | Promise
-
-type SignPartOptions = {
- uploadId: string
- key: string
- partNumber: number
- body: Blob
- signal?: AbortSignal
-}
-
-export type AwsS3UploadParameters =
- | {
- method: 'POST'
- url: string
- fields: Record
- expires?: number
- headers?: Record
- }
- | {
- method?: 'PUT'
- url: string
- fields?: Record
- expires?: number
- headers?: Record
- }
-
-export interface AwsS3Part {
- PartNumber?: number
- Size?: number
- ETag?: string
}
-type AWSS3WithCompanion = {
- endpoint: ConstructorParameters<
- typeof RequestClient
- >[1]['companionUrl']
- headers?: ConstructorParameters<
- typeof RequestClient
- >[1]['companionHeaders']
- cookiesRule?: ConstructorParameters<
- typeof RequestClient
- >[1]['companionCookiesRule']
- getTemporarySecurityCredentials?: true
-}
-type AWSS3WithoutCompanion = {
- getTemporarySecurityCredentials?: (options?: {
- signal?: AbortSignal
- }) => MaybePromise
- uploadPartBytes?: (options: {
- signature: AwsS3UploadParameters
- body: FormData | Blob
- size?: number
- onProgress: any
- onComplete: any
- signal?: AbortSignal
- }) => Promise
-}
-
-// biome-ignore lint/complexity/noBannedTypes: ...
-type AWSS3NonMultipartWithCompanionMandatory = {
- // No related options
-}
-
-type AWSS3NonMultipartWithoutCompanionMandatory<
- M extends Meta,
- B extends Body,
-> = {
- getUploadParameters: (
- file: UppyFile,
- options: RequestOptions,
- ) => MaybePromise
-}
-type AWSS3NonMultipartWithCompanion = AWSS3WithCompanion &
- AWSS3NonMultipartWithCompanionMandatory & {
- shouldUseMultipart: false
- }
-
-type AWSS3NonMultipartWithoutCompanion<
- M extends Meta,
- B extends Body,
-> = AWSS3WithoutCompanion &
- AWSS3NonMultipartWithoutCompanionMandatory & {
- shouldUseMultipart: false
- }
-
-type AWSS3MultipartWithoutCompanionMandatorySignPart<
- M extends Meta,
- B extends Body,
-> = {
- signPart: (
- file: UppyFile,
- opts: SignPartOptions,
- ) => MaybePromise
-}
-type AWSS3MultipartWithoutCompanionMandatory = {
+export type AwsS3Options = PluginOpts & {
+ /**
+ * Whether to use multipart uploads.
+ * - `true`: Always use multipart
+ * - `false`: Always use simple PUT
+ * - `function`: Called with file, return true for multipart
+ * Default: Use multipart for files > 100MB
+ */
+ shouldUseMultipart?: boolean | ((file: UppyFile) => boolean)
getChunkSize?: (file: { size: number }) => number
- createMultipartUpload: (file: UppyFile) => MaybePromise
- listParts: (
- file: UppyFile,
- opts: UploadResultWithSignal,
- ) => MaybePromise
- abortMultipartUpload: (
- file: UppyFile,
- opts: UploadResultWithSignal,
- ) => MaybePromise
- completeMultipartUpload: (
- file: UppyFile,
- opts: {
- uploadId: string
- key: string
- parts: AwsS3Part[]
- signal: AbortSignal
- },
- ) => MaybePromise<{ location?: string }>
-} & AWSS3MultipartWithoutCompanionMandatorySignPart
-
-type AWSS3MultipartWithoutCompanion<
- M extends Meta,
- B extends Body,
-> = AWSS3WithoutCompanion &
- AWSS3MultipartWithoutCompanionMandatory & {
- shouldUseMultipart?: true
- }
-
-type AWSS3MultipartWithCompanion<
- M extends Meta,
- B extends Body,
-> = AWSS3WithCompanion &
- Partial> & {
- shouldUseMultipart?: true
- }
-
-type AWSS3MaybeMultipartWithCompanion<
- M extends Meta,
- B extends Body,
-> = AWSS3WithCompanion &
- Partial> &
- AWSS3NonMultipartWithCompanionMandatory & {
- shouldUseMultipart: (file: UppyFile) => boolean
- }
-
-type AWSS3MaybeMultipartWithoutCompanion<
- M extends Meta,
- B extends Body,
-> = AWSS3WithoutCompanion &
- AWSS3MultipartWithoutCompanionMandatory &
- AWSS3NonMultipartWithoutCompanionMandatory & {
- shouldUseMultipart: (file: UppyFile) => boolean
- }
-
-interface _AwsS3MultipartOptions extends PluginOpts {
allowedMetaFields?: string[] | boolean
+
+ /**
+ * Maximum number of files uploading concurrently.
+ * Each file uploads its parts sequentially.
+ *
+ * Default: 6 — chosen to match the browser's HTTP/1.1 per-origin connection
+ * limit. Most browsers allow 6 concurrent connections per host, so this
+ * prevents queueing at the browser level while maximizing throughput.
+ */
limit?: number
- retryDelays?: number[] | null
-}
-export type AwsS3MultipartOptions<
- M extends Meta,
- B extends Body,
-> = _AwsS3MultipartOptions &
- (
- | AWSS3NonMultipartWithCompanion
- | AWSS3NonMultipartWithoutCompanion
- | AWSS3MultipartWithCompanion
- | AWSS3MultipartWithoutCompanion
- | AWSS3MaybeMultipartWithCompanion
- | AWSS3MaybeMultipartWithoutCompanion
+ /**
+ * Custom function to generate the S3 object key.
+ * Default: `{randomId}-{filename}`
+ */
+ generateObjectKey?: (file: UppyFile) => string
+} & (
+ | {
+ /** S3 upload endpoint */
+ s3Endpoint: string
+
+ /** AWS region, required for signing */
+ region?: string
+
+ /**
+ * Function to retrieve temporary credentials for client-side signing.
+ * When provided, S3mini handles signing internally using SigV4.
+ * Alternative to signRequest or endpoint.
+ */
+ getCredentials: IT.GetCredentialsFn
+ }
+ | {
+ /**
+ * Custom function to sign requests.
+ * Called with request details, should return signed headers.
+ * Alternative to using Companion endpoint.
+ */
+ signRequest: IT.SignRequestFn
+ }
+ | {
+ /** Companion URL if you want to use Companion for signing */
+ companionEndpoint: string
+ }
)
-export type { AwsS3MultipartOptions as AwsS3Options }
+// ============================================================================
+// Constants
+// ============================================================================
+
+const MB = 1024 * 1024
const defaultOptions = {
+ shouldUseMultipart: (file: UppyFile) => (file.size || 0) > 100 * MB,
allowedMetaFields: true,
+ // 6 matches browser HTTP/1.1 per-origin connection limit
limit: 6,
- getTemporarySecurityCredentials: false as any,
- shouldUseMultipart: ((file: UppyFile) =>
- (file.size || 0) > 100 * 1024 * 1024) as any as true,
- retryDelays: [0, 1000, 3000, 5000],
-} satisfies Partial>
+} satisfies Partial>
-export type { AwsBody } from './utils.js'
+// ============================================================================
+// S3Uploader Types
+// ============================================================================
-export default class AwsS3Multipart<
- M extends Meta,
- B extends Body,
-> extends BasePlugin<
- DefinePluginOpts, keyof typeof defaultOptions> &
- // We also have a few dynamic options defined below:
- Pick<
- AWSS3MultipartWithoutCompanionMandatory,
- | 'getChunkSize'
- | 'createMultipartUpload'
- | 'listParts'
- | 'abortMultipartUpload'
- | 'completeMultipartUpload'
- > &
- Required> &
- Partial &
- AWSS3MultipartWithoutCompanionMandatorySignPart &
- AWSS3NonMultipartWithoutCompanionMandatory,
+export default class AwsS3 extends BasePlugin<
+ DefinePluginOpts, keyof typeof defaultOptions>,
M,
B
> {
static VERSION = packageJson.version
- #companionCommunicationQueue
-
- #client!: RequestClient
-
- protected requests: any
+ #s3Client!: S3Client
+ #queue!: TaskQueue
+ #uploaders: Record | null> = {}
- protected uploaderEvents: Record | null>
-
- protected uploaders: Record | null>
-
- constructor(uppy: Uppy, opts?: AwsS3MultipartOptions) {
- super(uppy, {
- ...defaultOptions,
- uploadPartBytes: AwsS3Multipart.uploadPartBytes,
- createMultipartUpload: null as any,
- listParts: null as any,
- abortMultipartUpload: null as any,
- completeMultipartUpload: null as any,
- signPart: null as any,
- getUploadParameters: null as any,
- ...opts,
- })
- // We need the `as any` here because of the dynamic default options.
+ constructor(uppy: Uppy, opts: AwsS3Options) {
+ super(uppy, { ...defaultOptions, ...opts })
this.type = 'uploader'
- this.id = this.opts.id || 'AwsS3Multipart'
- this.#setClient(opts)
-
- const dynamicDefaultOptions = {
- createMultipartUpload: this.createMultipartUpload,
- listParts: this.listParts,
- abortMultipartUpload: this.abortMultipartUpload,
- completeMultipartUpload: this.completeMultipartUpload,
- signPart: opts?.getTemporarySecurityCredentials
- ? this.createSignedURL
- : this.signPart,
- getUploadParameters: opts?.getTemporarySecurityCredentials
- ? (this.createSignedURL as any)
- : this.getUploadParameters,
- } satisfies Partial>
-
- for (const key of Object.keys(dynamicDefaultOptions)) {
- if (this.opts[key as keyof typeof dynamicDefaultOptions] == null) {
- this.opts[key as keyof typeof dynamicDefaultOptions] =
- dynamicDefaultOptions[key as keyof typeof dynamicDefaultOptions].bind(
- this,
- )
- }
- }
-
- /**
- * Simultaneous upload limiting is shared across all uploads with this plugin.
- *
- * @type {RateLimitedQueue}
- */
- this.requests =
- (this.opts as any).rateLimitedQueue ??
- new RateLimitedQueue(this.opts.limit)
- this.#companionCommunicationQueue = new HTTPCommunicationQueue(
- this.requests,
- this.opts,
- this.#setS3MultipartState,
- this.#getFile,
- )
-
- this.uploaders = Object.create(null)
- this.uploaderEvents = Object.create(null)
- }
-
- private [Symbol.for('uppy test: getClient')]() {
- return this.#client
- }
-
- #setClient(opts?: Partial>) {
- 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>): void {
- this.#companionCommunicationQueue.setOptions(newOptions)
- super.setOptions(newOptions as any)
- this.#setClient(newOptions)
- }
-
- /**
- * Clean up all references for a file's upload: the MultipartUploader instance,
- * any events related to the file, and the Companion WebSocket connection.
- *
- * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed.
- * This should be done when the user cancels the upload, not when the upload is completed or errored.
- */
- resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void {
- if (this.uploaders[fileID]) {
- this.uploaders[fileID]!.abort({ really: opts?.abort || false })
- this.uploaders[fileID] = null
- }
- if (this.uploaderEvents[fileID]) {
- this.uploaderEvents[fileID]!.remove()
- this.uploaderEvents[fileID] = null
- }
+ this.id = this.opts.id || 'AwsS3'
}
- #assertHost(method: string): void {
- if (!this.#client) {
- throw new Error(
- `Expected a \`endpoint\` option containing a URL, or if you are not using Companion, a custom \`${method}\` implementation.`,
- )
- }
- }
-
- createMultipartUpload(
- file: UppyFile,
- signal?: AbortSignal,
- ): Promise {
- this.#assertHost('createMultipartUpload')
- throwIfAborted(signal)
-
- const allowedMetaFields = getAllowedMetaFields(
- this.opts.allowedMetaFields,
- file.meta,
- )
- const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields })
-
- return this.#client
- .post(
- 's3/multipart',
- {
- filename: file.name,
- type: file.type,
- metadata,
- },
- { signal },
- )
- .then(assertServerError)
- }
-
- listParts(
- file: UppyFile,
- { key, uploadId, signal }: UploadResultWithSignal,
- oldSignal?: AbortSignal,
- ): Promise {
- signal ??= oldSignal
- this.#assertHost('listParts')
- throwIfAborted(signal)
-
- const filename = encodeURIComponent(key)
- return this.#client
- .get(
- `s3/multipart/${encodeURIComponent(uploadId!)}?key=${filename}`,
- { signal },
- )
- .then(assertServerError)
- }
-
- completeMultipartUpload(
- file: UppyFile,
- { key, uploadId, parts, signal }: MultipartUploadResultWithSignal,
- oldSignal?: AbortSignal,
- ): Promise {
- signal ??= oldSignal
- this.#assertHost('completeMultipartUpload')
- throwIfAborted(signal)
-
- const filename = encodeURIComponent(key)
- const uploadIdEnc = encodeURIComponent(uploadId!)
- return this.#client
- .post(
- `s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
- { parts: parts.map(({ ETag, PartNumber }) => ({ ETag, PartNumber })) },
- { signal },
- )
- .then(assertServerError)
+ install(): void {
+ this.#setResumableUploadsCapability(true)
+ this.#initS3Client()
+ this.#queue = new TaskQueue({ concurrency: this.opts.limit })
+ this.uppy.addUploader(this.#upload)
+ this.uppy.on('cancel-all', this.#handleCancelAll)
}
- #cachedTemporaryCredentials?: MaybePromise
-
- async #getTemporarySecurityCredentials(options?: RequestOptions) {
- 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 (getTemporarySecurityCredentials === true) {
- this.#assertHost('getTemporarySecurityCredentials')
- this.#cachedTemporaryCredentials = this.#client
- .get('s3/sts', options)
- .then(assertServerError)
- } else {
- this.#cachedTemporaryCredentials =
- (getTemporarySecurityCredentials as AWSS3WithoutCompanion['getTemporarySecurityCredentials'])!(
- options,
- )
+ uninstall(): void {
+ this.#setResumableUploadsCapability(false)
+ this.uppy.removeUploader(this.#upload)
+ this.uppy.off('cancel-all', this.#handleCancelAll)
+ this.#queue.clear()
+ // Abort and clean up any in-flight uploads
+ for (const fileId of Object.keys(this.#uploaders)) {
+ const uploader = this.#uploaders[fileId]
+ if (uploader) {
+ uploader.abort()
}
- this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
- setTimeout(
- () => {
- // At half the time left before expiration, we clear the cache. That's
- // an arbitrary tradeoff to limit the number of requests made to the
- // remote while limiting the risk of using an expired token in case the
- // clocks are not exactly synced.
- // The HTTP cache should be configured to ensure a client doesn't request
- // more tokens than it needs, but this timeout provides a second layer of
- // security in case the HTTP cache is disabled or misconfigured.
- this.#cachedTemporaryCredentials = null as any
- },
- (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500,
- )
}
-
- return this.#cachedTemporaryCredentials
}
- async createSignedURL(
- file: UppyFile,
- options: SignPartOptions,
- ): Promise {
- const data = await this.#getTemporarySecurityCredentials(options)
- const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS.
-
- const { uploadId, key, partNumber } = options
-
- // Return an object in the correct shape.
- return {
- method: 'PUT',
- expires,
- fields: {},
- url: `${await createSignedURL({
- accountKey: data.credentials.AccessKeyId,
- accountSecret: data.credentials.SecretAccessKey,
- sessionToken: data.credentials.SessionToken,
- expires,
- bucketName: data.bucket,
- Region: data.region,
- Key: key ?? `${crypto.randomUUID()}-${file.name}`,
- uploadId,
- partNumber,
- })}`,
- // Provide content type header required by S3
- headers: {
- 'Content-Type': file.type as string,
+ #setResumableUploadsCapability = (value: boolean): void => {
+ const { capabilities } = this.uppy.getState()
+ this.uppy.setState({
+ capabilities: {
+ ...capabilities,
+ resumableUploads: value,
},
- }
- }
-
- signPart(
- file: UppyFile,
- { uploadId, key, partNumber, signal }: SignPartOptions,
- ): Promise {
- this.#assertHost('signPart')
- throwIfAborted(signal)
-
- if (uploadId == null || key == null || partNumber == null) {
- throw new Error(
- 'Cannot sign without a key, an uploadId, and a partNumber',
- )
- }
-
- const filename = encodeURIComponent(key)
- return this.#client
- .get(
- `s3/multipart/${encodeURIComponent(uploadId)}/${partNumber}?key=${filename}`,
- { signal },
- )
- .then(assertServerError)
- }
-
- abortMultipartUpload(
- file: UppyFile,
- { key, uploadId, signal }: UploadResultWithSignal,
- ): Promise {
- this.#assertHost('abortMultipartUpload')
-
- const filename = encodeURIComponent(key)
- const uploadIdEnc = encodeURIComponent(uploadId!)
- return this.#client
- .delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, {
- signal,
- })
- .then(assertServerError)
- }
-
- getUploadParameters(
- file: UppyFile,
- options: RequestOptions,
- ): Promise {
- this.#assertHost('getUploadParameters')
- const { meta } = file
- const { type, name: filename } = meta
- const allowedMetaFields = getAllowedMetaFields(
- this.opts.allowedMetaFields,
- file.meta,
- )
- const metadata = getAllowedMetadata({
- meta,
- allowedMetaFields,
- querify: true,
})
-
- const query = new URLSearchParams({ filename, type, ...metadata } as Record<
- string,
- string
- >)
-
- return this.#client.get(`s3/params?${query}`, options)
}
- static async uploadPartBytes({
- signature: { url, expires, headers, method = 'PUT' },
- body,
- size = (body as Blob).size,
- onProgress,
- onComplete,
- signal,
- }: {
- signature: AwsS3UploadParameters
- body: FormData | Blob
- size?: number
- onProgress: any
- onComplete: any
- signal?: AbortSignal
- }): Promise {
- throwIfAborted(signal)
+ #handleCancelAll = (): void => {
+ this.#setResumableUploadsCapability(true)
+ this.#queue.clear()
+ }
- if (url == null) {
- throw new Error('Cannot upload to an undefined URL')
- }
+ // --------------------------------------------------------------------------
+ // S3 Client Initialization
+ // --------------------------------------------------------------------------
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest()
- xhr.open(method, url, true)
- if (headers) {
- Object.keys(headers).forEach((key) => {
- xhr.setRequestHeader(key, headers[key])
- })
+ #initS3Client(): void {
+ if ('companionEndpoint' in this.opts) {
+ if (typeof this.opts.companionEndpoint !== 'string') {
+ throw new TypeError('companionEndpoint must be a string')
}
- xhr.responseType = 'text'
- if (typeof expires === 'number') {
- xhr.timeout = expires * 1000
+ this.#s3Client = new S3Companion({
+ companionEndpoint: this.opts.companionEndpoint,
+ })
+ } else if ('getCredentials' in this.opts) {
+ if (typeof this.opts.s3Endpoint !== 'string') {
+ throw new TypeError('s3Endpoint must be a string')
}
-
- function onabort() {
- xhr.abort()
+ if (typeof this.opts.getCredentials !== 'function') {
+ throw new TypeError('getCredentials must be a function')
}
- function cleanup() {
- signal?.removeEventListener('abort', onabort)
+ if (this.opts.region != null && typeof this.opts.region !== 'string') {
+ throw new TypeError('region must be a string')
}
- signal?.addEventListener('abort', onabort)
- xhr.upload.addEventListener('progress', (ev) => {
- onProgress(ev)
+ // Mode: Temporary credentials (client-side signing)
+ this.#s3Client = new S3mini({
+ endpoint: this.opts.s3Endpoint,
+ getCredentials: this.opts.getCredentials,
+ region: this.opts.region,
})
-
- xhr.addEventListener('abort', () => {
- cleanup()
-
- reject(createAbortError())
+ } else if ('signRequest' in this.opts) {
+ if (typeof this.opts.signRequest !== 'function') {
+ throw new TypeError('signRequest must be a function')
+ }
+ // Mode: Custom signing function
+ this.#s3Client = new S3mini({
+ signRequest: this.opts.signRequest,
})
+ } else {
+ throw new TypeError(
+ 'One of options `companionEndpoint`, `signRequest`, or `getCredentials` is required',
+ )
+ }
+ }
- xhr.addEventListener('timeout', () => {
- cleanup()
+ // --------------------------------------------------------------------------
+ // Upload Entry Point
+ // --------------------------------------------------------------------------
- const error = new Error('Request has expired')
- ;(error as any).source = { status: 403 }
- reject(error)
- })
- xhr.addEventListener('load', () => {
- cleanup()
+ #upload = async (fileIDs: string[]): Promise => {
+ if (fileIDs.length === 0) return
- if (
- xhr.status === 403 &&
- xhr.responseText.includes('Request has expired')
- ) {
- const error = new Error('Request has expired')
- ;(error as any).source = xhr
- reject(error)
- return
- }
- if (xhr.status < 200 || xhr.status >= 300) {
- const error = new Error('Non 2xx')
- ;(error as any).source = xhr
- reject(error)
- return
- }
-
- onProgress?.({ loaded: size, lengthComputable: true })
+ const files = this.uppy.getFilesByIds(fileIDs)
+ const filesToUpload = filterFilesToUpload(files)
+ const filesToEmit = filterFilesToEmitUploadStarted(filesToUpload)
- // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders#examples
- const arr = xhr
- .getAllResponseHeaders()
- .trim()
- .split(/[\r\n]+/)
- // @ts-expect-error null is allowed to avoid inherited properties
- const headersMap: Record = { __proto__: null }
- for (const line of arr) {
- const parts = line.split(': ')
- const header = parts.shift()!
- const value = parts.join(': ')
- headersMap[header] = value
- }
- const { etag, location } = headersMap
+ this.uppy.emit('upload-start', filesToEmit)
- // More info bucket settings when this is not present:
- // https://github.com/transloadit/uppy/issues/5388#issuecomment-2464885562
- if (method.toUpperCase() === 'POST' && location == null) {
- // Not being able to read the Location header is not a fatal error.
- console.error(
- '@uppy/aws-s3: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket',
- )
- }
- if (etag == null) {
- console.error(
- '@uppy/aws-s3: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket',
- )
+ const promises = filesToUpload.map((file) => {
+ if (file.isRemote) {
+ // Remote uploads are queued internally by RequestClient.uploadRemoteFile()
+ // via getQueue(), so no outer queue wrapping is needed here.
+ return this.#uploadRemoteFile(file)
+ }
+ return this.#queue.add(async () => {
+ // File may have been removed while waiting in the queue.
+ // Unlike actively uploading files, queued files don't have an S3Uploader
+ // instance yet, so there's no event listener to catch the removal.
+ // Re-fetch the file to ensure it still exists before starting upload.
+ const currentFile = this.uppy.getFile(file.id)
+ if (!currentFile) {
return
}
-
- onComplete?.(etag)
- resolve({
- ...headersMap,
- ETag: etag, // keep capitalised ETag for backwards compatiblity
- })
- })
-
- xhr.addEventListener('error', (ev) => {
- cleanup()
-
- const error = new Error('Unknown error')
- ;(error as any).source = ev.target
- reject(error)
+ return this.#uploadLocalFile(currentFile as LocalUppyFile) // assume it's still a local file since remote files aren't queued
})
-
- xhr.send(body)
})
- }
-
- #setS3MultipartState = (
- file: UppyFile,
- { key, uploadId }: UploadResult,
- ) => {
- const cFile = this.uppy.getFile(file.id)
- if (cFile == null) {
- // file was removed from store
- return
- }
- this.uppy.setFileState(file.id, {
- s3Multipart: {
- ...(cFile as MultipartFile).s3Multipart,
- key,
- uploadId,
- },
- } as Partial>)
- }
-
- #getFile = (file: UppyFile) => {
- return this.uppy.getFile(file.id) || file
+ await Promise.allSettled(promises)
+ // After the upload batch is done, restore resumable uploads capability.
+ // It may have been set to false if there were remote files in this batch.
+ this.#setResumableUploadsCapability(true)
}
- #uploadLocalFile(file: LocalUppyFile) {
- return new Promise((resolve, reject) => {
- const onProgress = (bytesUploaded: number, bytesTotal: number) => {
- const latestFile = this.uppy.getFile(file.id)
- this.uppy.emit('upload-progress', latestFile, {
- uploadStarted: latestFile.progress.uploadStarted ?? 0,
- bytesUploaded,
- bytesTotal,
- })
- }
-
- const onError = (err: unknown) => {
- this.uppy.log(err as Error)
- this.uppy.emit('upload-error', file, err as Error)
-
- this.resetUploaderReferences(file.id)
- reject(err)
- }
-
- const onSuccess = (result: B) => {
- const uploadResp = {
- body: {
- ...result,
+ // --------------------------------------------------------------------------
+ // Local File Upload
+ // --------------------------------------------------------------------------
+
+ async #uploadLocalFile(file: LocalUppyFile): Promise {
+ try {
+ return await new Promise((resolve, reject) => {
+ // Create uploader (events are wired internally).
+ // S3Uploader detects resume state from file.s3Multipart internally.
+ const uploader = new S3Uploader({
+ uppy: this.uppy,
+ s3Client: this.#s3Client,
+ file,
+ metadata: this.#getAllowedMeta(file),
+ key: this.#generateKey(file),
+ shouldUseMultipart: this.#shouldUseMultipart(file),
+ getChunkSize: this.opts.getChunkSize,
+ log: (...args) => this.uppy.log(...args),
+
+ onProgress: (bytesUploaded, bytesTotal) => {
+ this.uppy.emit('upload-progress', file, {
+ uploadStarted: file.progress.uploadStarted ?? Date.now(),
+ bytesUploaded,
+ bytesTotal,
+ })
},
- status: 200,
- uploadURL: result.location as string,
- }
-
- this.resetUploaderReferences(file.id)
-
- this.uppy.emit('upload-success', this.#getFile(file), uploadResp)
-
- if (result.location) {
- this.uppy.log(`Download ${file.name} from ${result.location}`)
- }
-
- resolve(undefined)
- }
- if (file.data == null) throw new Error('File data is empty')
-
- const upload = new MultipartUploader(file.data, {
- // .bind to pass the file object to each handler.
- companionComm: this.#companionCommunicationQueue,
-
- log: (...args: Parameters['log']>) => this.uppy.log(...args),
- getChunkSize: this.opts.getChunkSize
- ? this.opts.getChunkSize.bind(this)
- : undefined,
-
- onProgress,
- onError,
- onSuccess,
- onPartComplete: (part) => {
- this.uppy.emit(
- 's3-multipart:part-uploaded',
- this.#getFile(file),
- part,
- )
- },
-
- file,
- shouldUseMultipart: this.opts.shouldUseMultipart,
-
- ...(file as MultipartFile).s3Multipart,
- })
-
- this.uploaders[file.id] = upload
- const eventManager = new EventManager(this.uppy)
- this.uploaderEvents[file.id] = eventManager
+ onPartComplete: (part) => {
+ this.uppy.emit('s3-multipart:part-uploaded', file, part)
+ },
- eventManager.onFileRemove(file.id, (removed) => {
- upload.abort()
- this.resetUploaderReferences(file.id, { abort: true })
- resolve(`upload ${removed} was removed`)
- })
+ onSuccess: (result: UploadResult) => {
+ this.uppy.emit('upload-success', file, {
+ status: 200,
+ body: {
+ location: result.location,
+ key: result.key,
+ } satisfies AwsBody as unknown as B,
+ uploadURL: result.location,
+ })
+ resolve()
+ },
- eventManager.onCancelAll(file.id, () => {
- upload.abort()
- this.resetUploaderReferences(file.id, { abort: true })
- resolve(`upload ${file.id} was canceled`)
- })
+ onError: (err) => {
+ this.uppy.emit('upload-error', file, err)
+ reject(err)
+ },
- eventManager.onFilePause(file.id, (isPaused) => {
- if (isPaused) {
- upload.pause()
- } else {
- upload.start()
- }
- })
+ onAbort: () => {
+ resolve() // Normal completion, not an error
+ },
+ })
- eventManager.onPauseAll(file.id, () => {
- upload.pause()
- })
+ // Store uploader for external abort if needed
+ this.#uploaders[file.id] = uploader
- eventManager.onResumeAll(file.id, () => {
- upload.start()
+ // Start the upload
+ uploader.start()
})
-
- upload.start()
- })
+ } finally {
+ // Clean up uploader instance after upload completes or fails
+ delete this.#uploaders[file.id]
+ }
}
- #getCompanionClientArgs(file: UppyFile) {
- return {
- ...('remote' in file && file.remote?.body),
- protocol: 's3-multipart',
- size: file.data!.size,
- metadata: file.meta,
+ #shouldUseMultipart(file: UppyFile): boolean {
+ const { shouldUseMultipart } = this.opts
+ if (typeof shouldUseMultipart === 'function') {
+ return shouldUseMultipart(file)
+ }
+ if (typeof shouldUseMultipart === 'boolean') {
+ return shouldUseMultipart
}
+ // Default: multipart for files > 100MB
+ return (file.size ?? 0) > 100 * MB
}
- #upload = async (fileIDs: string[]) => {
- if (fileIDs.length === 0) return undefined
-
- const files = this.uppy.getFilesByIds(fileIDs)
- const filesFiltered = filterFilesToUpload(files)
- const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
-
- this.uppy.emit('upload-start', filesToEmit)
-
- const promises = filesFiltered.map((file) => {
- if (file.isRemote) {
- const getQueue = () => this.requests
- this.#setResumableUploadsCapability(false)
- const controller = new AbortController()
-
- const removedHandler = (removedFile: UppyFile) => {
- if (removedFile.id === file.id) controller.abort()
- }
- this.uppy.on('file-removed', removedHandler)
-
- const uploadPromise = this.uppy
- .getRequestClientForFile>(file)
- .uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
- signal: controller.signal,
- getQueue,
- })
-
- this.requests.wrapSyncFunction(
- () => {
- this.uppy.off('file-removed', removedHandler)
- },
- { priority: -1 },
- )()
-
- return uploadPromise
- }
-
- return this.#uploadLocalFile(file)
- })
-
- const upload = await Promise.allSettled(promises)
- // After the upload is done, another upload may happen with only local files.
- // We reset the capability so that the next upload can use resumable uploads.
- this.#setResumableUploadsCapability(true)
- return upload
+ #generateKey(file: UppyFile): string {
+ return this.opts.generateObjectKey?.(file) ?? file.name
}
- #setCompanionHeaders = () => {
- this.#client?.setCompanionHeaders(this.opts.headers!)
- }
+ // --------------------------------------------------------------------------
+ // Remote File Upload
+ // --------------------------------------------------------------------------
- #setResumableUploadsCapability = (boolean: boolean) => {
- const { capabilities } = this.uppy.getState()
- this.uppy.setState({
- capabilities: {
- ...capabilities,
- resumableUploads: boolean,
- },
- })
+ #getAllowedMeta(file: UppyFile) {
+ const allowedMetaFields = getAllowedMetaFields(
+ this.opts.allowedMetaFields,
+ file.meta,
+ )
+ return Object.fromEntries(
+ allowedMetaFields.map((key) => [key, file.meta[key]]),
+ )
}
- #resetResumableCapability = () => {
- this.#setResumableUploadsCapability(true)
+ /**
+ * Builds the request body sent to Companion's provider get endpoint.
+ * Tells Companion to use its server-side S3 upload path.
+ */
+ #getCompanionClientArgs(file: RemoteUppyFile): Record {
+ return {
+ ...file.remote.body,
+ protocol: 's3-multipart',
+ size: file.data.size,
+ metadata: this.#getAllowedMeta(file),
+ }
}
- install(): void {
- this.#setResumableUploadsCapability(true)
- this.uppy.addPreProcessor(this.#setCompanionHeaders)
- this.uppy.addUploader(this.#upload)
- this.uppy.on('cancel-all', this.#resetResumableCapability)
- }
+ async #uploadRemoteFile(file: RemoteUppyFile): Promise {
+ this.#setResumableUploadsCapability(false)
- uninstall(): void {
- this.uppy.removePreProcessor(this.#setCompanionHeaders)
- this.uppy.removeUploader(this.#upload)
- this.uppy.off('cancel-all', this.#resetResumableCapability)
+ const controller = new AbortController()
+
+ const removedHandler = (removedFile: UppyFile) => {
+ if (removedFile.id === file.id) controller.abort()
+ }
+ this.uppy.on('file-removed', removedHandler)
+
+ try {
+ await this.uppy
+ .getRequestClientForFile>(file)
+ .uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
+ signal: controller.signal,
+ getQueue: () => this.#queue,
+ })
+ } finally {
+ this.uppy.off('file-removed', removedHandler)
+ }
}
}
-export type uploadPartBytes = (typeof AwsS3Multipart<
- any,
- any
->)['uploadPartBytes']
+export type { AwsS3Options as AwsS3MultipartOptions }
-export type {
- MultipartUploadResult,
- MultipartUploadResultWithSignal,
- UploadPartBytesResult,
- UploadResult,
- UploadResultWithSignal,
-} from './utils.js'
+/** Body type for AWS S3 upload responses */
+export interface AwsBody extends Body {
+ location: string
+ key: string
+}
diff --git a/packages/@uppy/aws-s3/src/s3-client/CompanionS3.ts b/packages/@uppy/aws-s3/src/s3-client/CompanionS3.ts
new file mode 100644
index 0000000000..749f42f244
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/CompanionS3.ts
@@ -0,0 +1,212 @@
+import * as C from './consts.js'
+import S3Client from './S3Client.js'
+import type * as IT from './types.js'
+import * as U from './utils.js'
+
+/**
+ * S3Companion is an S3Client that interacts with a Companion server to perform S3 operations.
+ */
+class S3Companion extends S3Client {
+ readonly companionEndpoint: string
+
+ constructor({
+ companionEndpoint,
+ requestAbortTimeout,
+ }: { companionEndpoint: string; requestAbortTimeout?: number | undefined }) {
+ super({ requestAbortTimeout })
+ this.companionEndpoint = companionEndpoint
+ }
+
+ private async _fetch(path: string, opts?: RequestInit) {
+ const response = await fetch(`${this.companionEndpoint}/s3${path}`, opts)
+ if (!response.ok) {
+ throw new Error(`Companion request failed: ${response.statusText}`)
+ }
+ return response
+ }
+
+ private async _request({
+ url,
+ method,
+ data,
+ onProgress,
+ signal,
+ contentType,
+ }: {
+ url: string
+ method: IT.HttpMethod
+ data?: XMLHttpRequestBodyInit
+ onProgress?: IT.OnProgressFn
+ signal?: AbortSignal
+ contentType?: string
+ }): Promise {
+ // Wait for online before starting
+ await this.waitForOnline(signal)
+
+ return this.xhr({ url, method, data, onProgress, signal, contentType })
+ }
+
+ public override async putObject(
+ keyIn: string,
+ data: Blob | File,
+ fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
+ metadata: Record,
+ onProgress?: IT.OnProgressFn,
+ signal?: AbortSignal,
+ ) {
+ const response = await this._fetch(
+ `/params?${new URLSearchParams({ filename: keyIn, type: fileType, ...Object.fromEntries(Object.entries(metadata).map(([k, v]) => [`metadata[${k}]`, String(v)])) })}`,
+ )
+ const { url, fields }: { url: string; fields: Record } =
+ await response.json()
+
+ const formData = new FormData()
+ Object.entries(fields).forEach(([key, value]) => formData.set(key, value))
+ formData.set('file', data)
+
+ const xhr = await this._request({
+ url,
+ method: 'POST',
+ data: formData,
+ onProgress,
+ signal,
+ })
+
+ return {
+ location: `${url}${fields.key}`, // `url` is returned by the signer as the bucket URL without any path, but trailing slash, so we need to add the key (path) to get the full object URL
+ etag: U.sanitizeETag(xhr.getResponseHeader('etag')),
+ key: fields.key,
+ }
+ }
+
+ public override async createMultipartUpload(
+ keyIn: string,
+ fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
+ metadata: Record,
+ ) {
+ if (typeof fileType !== 'string') {
+ throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`)
+ }
+
+ const method = 'POST'
+ const response = await this._fetch('/multipart', {
+ method,
+ body: JSON.stringify({ filename: keyIn, metadata, type: fileType }),
+ headers: { 'content-type': 'application/json' },
+ })
+ const {
+ key,
+ uploadId,
+ }: { key?: string; uploadId?: string; bucket?: string } =
+ await response.json()
+ if (uploadId == null) throw new Error('No uploadId returned')
+ if (key == null) throw new Error('No key returned')
+
+ return { uploadId, key }
+ }
+
+ public override async uploadPart(
+ key: string,
+ uploadId: string,
+ data: XMLHttpRequestBodyInit,
+ partNumber: number,
+ onProgress?: IT.OnProgressFn,
+ signal?: AbortSignal,
+ ) {
+ const response = await this._fetch(
+ `/multipart/${encodeURIComponent(uploadId)}/${encodeURIComponent(partNumber)}?${new URLSearchParams({ key })}`,
+ {
+ method: 'GET',
+ },
+ )
+
+ const { url }: { url: string } = await response.json()
+
+ const xhr = await this._request({
+ url,
+ method: 'PUT',
+ data,
+ onProgress,
+ signal,
+ })
+
+ const etag = U.sanitizeETag(xhr.getResponseHeader('etag'))
+ if (etag == null) {
+ throw new Error(
+ `${C.ERROR_PREFIX}Missing ETag in uploadPart response headers`,
+ )
+ }
+
+ return { etag }
+ }
+
+ public override async listParts(
+ uploadId: string,
+ key: string,
+ ): Promise {
+ if (!uploadId) {
+ throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED)
+ }
+
+ const response = await this._fetch(
+ `/multipart/${encodeURIComponent(uploadId)}?${new URLSearchParams({ key })}`,
+ {
+ method: 'GET',
+ },
+ )
+
+ const parts: { PartNumber: string; ETag: string }[] = await response.json()
+
+ return parts.map((p) => ({
+ partNumber: parseInt(String(p.PartNumber), 10),
+ etag: String(p.ETag),
+ }))
+ }
+
+ public override async completeMultipartUpload(
+ key: string,
+ uploadId: string,
+ parts: Array,
+ ) {
+ const response = await this._fetch(
+ `/multipart/${encodeURIComponent(uploadId)}/complete?${new URLSearchParams({ key })}`,
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ parts: parts.map((part) => ({
+ PartNumber: part.partNumber,
+ ETag: part.etag,
+ })),
+ }),
+ headers: { 'content-type': 'application/json' },
+ },
+ )
+
+ const {
+ location,
+ bucket,
+ key: resultKey,
+ }: {
+ location: string
+ bucket?: string
+ key: string
+ } = await response.json()
+
+ return {
+ location,
+ bucket,
+ key: resultKey,
+ }
+ }
+
+ public override async abortMultipartUpload(key: string, uploadId: string) {
+ await this._fetch(
+ `/multipart/${encodeURIComponent(uploadId)}?${new URLSearchParams({ key })}`,
+ {
+ method: 'DELETE',
+ },
+ )
+ }
+}
+
+export default S3Companion
diff --git a/packages/@uppy/aws-s3/src/s3-client/S3Client.ts b/packages/@uppy/aws-s3/src/s3-client/S3Client.ts
new file mode 100644
index 0000000000..80c1923a2b
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/S3Client.ts
@@ -0,0 +1,197 @@
+import { fetcher } from '@uppy/utils'
+import * as C from './consts.js'
+import type * as IT from './types.js'
+
+class S3Client {
+ readonly requestAbortTimeout?: number
+
+ constructor({
+ requestAbortTimeout,
+ ...rest
+ }: { requestAbortTimeout?: number | undefined }) {
+ this.requestAbortTimeout = requestAbortTimeout
+ }
+
+ /**
+ * Helper to check if we're currently offline in a browser context.
+ */
+ protected isOffline(): boolean {
+ return typeof navigator !== 'undefined' && navigator.onLine === false
+ }
+
+ /**
+ * Waits for the browser to come back online.
+ * Returns a promise that resolves when the 'online' event fires,
+ * or rejects if the abort signal is triggered.
+ */
+ protected waitForOnline(signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.isOffline()) {
+ resolve()
+ return
+ }
+ // Already online or not in browser
+ if (
+ typeof navigator === 'undefined' ||
+ navigator.onLine === true ||
+ navigator.onLine === undefined
+ ) {
+ resolve()
+ return
+ }
+
+ // Already aborted
+ if (signal?.aborted) {
+ reject(new DOMException('Upload aborted', 'AbortError'))
+ return
+ }
+
+ const cleanup = () => {
+ window.removeEventListener('online', onOnline)
+ signal?.removeEventListener('abort', onAbort)
+ }
+
+ const onOnline = () => {
+ cleanup()
+ resolve()
+ }
+
+ const onAbort = () => {
+ cleanup()
+ reject(new DOMException('Upload aborted', 'AbortError'))
+ }
+
+ window.addEventListener('online', onOnline)
+ signal?.addEventListener('abort', onAbort)
+ })
+ }
+
+ protected async xhr({
+ url,
+ method,
+ data,
+ onProgress,
+ signal,
+ contentType,
+ }: {
+ url: string
+ method: IT.HttpMethod
+ data?: XMLHttpRequestBodyInit
+ onProgress?: IT.OnProgressFn
+ signal?: AbortSignal
+ contentType?: string
+ }) {
+ // Check if aborted while waiting for online
+ if (signal?.aborted) {
+ throw new DOMException('Request aborted', 'AbortError')
+ }
+
+ return fetcher(url, {
+ method,
+ // XHR natively supports ArrayBuffer, Uint8Array, Blob, and string
+ body: ['GET', 'HEAD'].includes(method) ? undefined : data,
+ headers: contentType ? { 'Content-Type': contentType } : {},
+ signal,
+ timeout: this.requestAbortTimeout,
+ retries: 3,
+ /**
+ * Retry logic:
+ * - Retries: 5xx server errors, 429 rate limiting
+ * - Skips: 4xx client errors (except 429), offline (handled separately)
+ */
+ shouldRetry: (xhr) => {
+ // If offline, don't retry via fetcher - our handler will resume
+ if (this.isOffline()) return false
+ // Don't retry client errors (except 429 rate limit)
+ if (xhr.status >= 400 && xhr.status < 500 && xhr.status !== 429) {
+ return false
+ }
+ return true
+ },
+ onUploadProgress: (event) => {
+ if (event.lengthComputable && onProgress) {
+ onProgress(event.loaded, event.total)
+ }
+ },
+ onTimeout: (timeout) => {
+ // Log stall detection - upload will continue but may be slow
+ console.warn(
+ `[S3mini] Upload stalled - no progress for ${Math.ceil(timeout / 1000)}s`,
+ )
+ },
+ })
+ }
+
+ public async putObject(
+ key: string,
+ data: XMLHttpRequestBodyInit,
+ fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
+ metadata: Record,
+ onProgress?: IT.OnProgressFn,
+ signal?: AbortSignal,
+ ): Promise<{
+ location: string
+ key: string
+ etag: string | undefined
+ }> {
+ throw new Error('Not implemented')
+ }
+
+ public async createMultipartUpload(
+ key: string,
+ fileType?: string,
+ // @ts-expect-error unused
+ metadata: Record,
+ ): Promise<{
+ uploadId: string
+ key: string
+ }> {
+ throw new Error('Not implemented')
+ }
+
+ public async uploadPart(
+ key: string,
+ uploadId: string,
+ data: XMLHttpRequestBodyInit,
+ partNumber: number,
+ onProgress?: IT.OnProgressFn,
+ signal?: AbortSignal,
+ ): Promise<{
+ etag: string
+ }> {
+ throw new Error('Not implemented')
+ }
+
+ public async listParts(
+ uploadId: string,
+ key: string,
+ ): Promise {
+ throw new Error('Not implemented')
+ }
+
+ public async completeMultipartUpload(
+ key: string,
+ uploadId: string,
+ parts: IT.UploadPart[],
+ ): Promise<{
+ location: string
+ bucket: string | undefined
+ key: string
+ etag?: string | undefined
+ }> {
+ throw new Error('Not implemented')
+ }
+
+ public async abortMultipartUpload(
+ key: string,
+ uploadId: string,
+ ): Promise {
+ throw new Error('Not implemented')
+ }
+
+ public async deleteObject(key: string): Promise {
+ throw new Error('Not implemented')
+ }
+}
+
+export default S3Client
diff --git a/packages/@uppy/aws-s3/src/s3-client/S3mini.ts b/packages/@uppy/aws-s3/src/s3-client/S3mini.ts
new file mode 100644
index 0000000000..060be670b8
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/S3mini.ts
@@ -0,0 +1,567 @@
+/**
+ * Taken from https://github.com/good-lly/s3mini.git, by Jølly Good, under MIT license.
+ * Modified to make it work with Uppy.
+ */
+
+import * as C from './consts.js'
+import S3Client from './S3Client.js'
+import { createSigV4Signer } from './signer.js'
+import type * as IT from './types.js'
+import * as U from './utils.js'
+
+/**
+ * S3 client for browser-compatible interaction with S3-compatible storage.
+ * Supports simple uploads, multipart uploads, and object deletion.
+ *
+ * @example
+ * // Option 1: With signRequest callback (no endpoint needed)
+ * const s3 = new S3mini({
+ * signRequest: async ({ method, key, uploadId, partNumber }) => {
+ * const resp = await fetch('/api/s3/sign', {
+ * method: 'POST',
+ * body: JSON.stringify({ method, key, uploadId, partNumber }),
+ * });
+ * return resp.json(); // { url }
+ * },
+ * });
+ *
+ * // Option 2: With getCredentials callback (endpoint required for client-side signing)
+ * const s3 = new S3mini({
+ * endpoint: 'https://s3.us-east-1.amazonaws.com/my-bucket',
+ * getCredentials: async () => {
+ * const resp = await fetch('/api/s3/credentials');
+ * return resp.json(); // { credentials, region }
+ * },
+ * });
+ *
+ * await s3.putObject('file.txt', 'Hello, World!');
+ */
+class S3mini extends S3Client {
+ readonly endpoint?: URL
+ readonly region: string
+ readonly requestSizeInBytes: number
+
+ private readonly getCredentials?: IT.GetCredentialsFn
+ private cachedCredentials?: IT.CredentialsResponse
+ private cachedCredentialsPromise?: Promise
+ private signRequest!: IT.SignRequestFn
+
+ constructor({
+ region = 'auto',
+ requestSizeInBytes = C.DEFAULT_REQUEST_SIZE_IN_BYTES,
+ requestAbortTimeout,
+ ...rest
+ }: IT.S3Config) {
+ super({ requestAbortTimeout })
+ if ('signRequest' in rest) {
+ const { signRequest } = rest
+ if (!signRequest) {
+ throw new TypeError(
+ 'Either signRequest or getCredentials must be provided',
+ )
+ }
+
+ if (signRequest && typeof signRequest !== 'function') {
+ throw new TypeError('signRequest must be a function')
+ }
+
+ this.signRequest = signRequest
+ } else if ('getCredentials' in rest) {
+ const { getCredentials, endpoint } = rest
+ if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
+ throw new TypeError(C.ERROR_ENDPOINT_REQUIRED)
+ }
+ if (getCredentials && typeof getCredentials !== 'function') {
+ throw new TypeError('getCredentials must be a function')
+ }
+ this.endpoint = new URL(this._ensureValidUrl(endpoint))
+
+ this.getCredentials = getCredentials
+ this.signRequest = this._createCredentialBasedSigner()
+ } else {
+ throw new TypeError(
+ 'Either signRequest or getCredentials must be provided',
+ )
+ }
+
+ this.region = region
+ this.requestSizeInBytes = requestSizeInBytes
+ }
+
+ /** Creates a presigner that fetches/caches credentials and generates pre-signed URLs. */
+ private _createCredentialBasedSigner(): IT.SignRequestFn {
+ return async (
+ request: IT.PresignableRequest,
+ ): Promise => {
+ const creds = await this._getCachedCredentials()
+ if (this.endpoint == null) {
+ throw new Error('Endpoint is required for credential-based signing')
+ }
+ const presigner = createSigV4Signer({
+ accessKeyId: creds.credentials.accessKeyId,
+ secretAccessKey: creds.credentials.secretAccessKey,
+ sessionToken: creds.credentials.sessionToken,
+ region: creds.region || this.region,
+ endpoint: this.endpoint.toString(),
+ })
+ return presigner(request)
+ }
+ }
+
+ /** Gets cached credentials or fetches new ones. */
+ private async _getCachedCredentials(): Promise {
+ // Return Cached Credentials if available
+ if (this.cachedCredentials != null) {
+ return this.cachedCredentials
+ }
+
+ // Cache the promise so concurrent calls wait for the same fetch
+ if (this.cachedCredentialsPromise == null) {
+ this.cachedCredentialsPromise = (async () => {
+ try {
+ const creds = await this.getCredentials!({})
+ this.cachedCredentials = creds
+ return creds
+ } finally {
+ // Clear promise cache after resolution to allow future retries
+ this.cachedCredentialsPromise = undefined
+ }
+ })()
+ }
+
+ return this.cachedCredentialsPromise
+ }
+
+ private _ensureValidUrl(raw: string): string {
+ const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`
+ try {
+ new URL(candidate)
+
+ // Find the last non-slash character
+ let endIndex = candidate.length
+ while (endIndex > 0 && candidate[endIndex - 1] === '/') {
+ endIndex--
+ }
+ return endIndex === candidate.length
+ ? candidate
+ : candidate.substring(0, endIndex)
+ } catch {
+ const msg = `${C.ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`
+ throw new TypeError(msg)
+ }
+ }
+
+ private _checkKey(key: string): void {
+ if (typeof key !== 'string' || key.trim().length === 0) {
+ throw new TypeError(C.ERROR_KEY_REQUIRED)
+ }
+ }
+
+ private _validateUploadPartParams(
+ key: string,
+ uploadId: string,
+ partNumber: number,
+ ): void {
+ this._checkKey(key)
+ if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
+ throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED)
+ }
+ if (!Number.isInteger(partNumber) || partNumber <= 0) {
+ throw new TypeError(
+ `${C.ERROR_PREFIX}partNumber must be a positive integer`,
+ )
+ }
+ }
+
+ /**
+ * Uploads an object to S3 using XHR for progress tracking.
+ * @param key - Object key
+ * @param data - Data to upload (Blob, ArrayBuffer, Uint8Array, or string)
+ * @param fileType - Content type
+ * @param onProgress - Optional progress callback
+ * @param signal - Optional abort signal
+ */
+ public override async putObject(
+ key: string,
+ data: XMLHttpRequestBodyInit,
+ fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
+ metadata?: Record,
+ onProgress?: IT.OnProgressFn,
+ signal?: AbortSignal,
+ ) {
+ this._checkKey(key)
+
+ const { xhr, url } = await this.request({
+ request: { method: 'PUT', key },
+ data,
+ onProgress,
+ signal,
+ contentType: fileType,
+ })
+
+ return {
+ location: U.removeQueryString(url),
+ etag: U.sanitizeETag(xhr.getResponseHeader('etag')),
+ key,
+ }
+ }
+
+ /** Initiates a multipart upload and returns the upload ID. */
+ public override async createMultipartUpload(
+ key: string,
+ fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
+ metadata?: Record, // todo support metadata here too?
+ ) {
+ this._checkKey(key)
+ if (typeof fileType !== 'string') {
+ throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`)
+ }
+
+ const { xhr } = await this.request({
+ request: { method: 'POST', key },
+ contentType: fileType,
+ })
+
+ const parsed = U.parseXml(xhr.responseText) as Record
+
+ if (parsed && typeof parsed === 'object') {
+ // Check for both cases of InitiateMultipartUploadResult
+ const uploadResult =
+ (parsed.initiateMultipartUploadResult as Record) ||
+ (parsed.InitiateMultipartUploadResult as Record)
+
+ if (uploadResult && typeof uploadResult === 'object') {
+ // Check for both cases of uploadId
+ const uploadId = uploadResult.uploadId || uploadResult.UploadId
+
+ if (uploadId && typeof uploadId === 'string') {
+ return { uploadId, key }
+ }
+ }
+ }
+
+ throw new Error(
+ `${C.ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(
+ parsed,
+ )}`,
+ )
+ }
+
+ public override async uploadPart(
+ key: string,
+ uploadId: string,
+ data: XMLHttpRequestBodyInit,
+ partNumber: number,
+ onProgress?: IT.OnProgressFn,
+ signal?: AbortSignal,
+ ) {
+ this._validateUploadPartParams(key, uploadId, partNumber)
+
+ const { xhr } = await this.request({
+ request: {
+ method: 'PUT',
+ key,
+ uploadId,
+ partNumber,
+ },
+ data,
+ onProgress,
+ signal,
+ })
+
+ const etag = U.sanitizeETag(xhr.getResponseHeader('etag'))
+ if (etag == null) {
+ throw new Error(
+ `${C.ERROR_PREFIX}Missing ETag in uploadPart response headers`,
+ )
+ }
+
+ return { etag }
+ }
+
+ /**
+ * Core XHR upload implementation using @uppy/utils/fetcher.
+ *
+ * Features:
+ * - Automatic retry with exponential backoff (3 attempts)
+ * - Offline detection with automatic resume on reconnect
+ * - Stall detection via ProgressTimeout
+ */
+ private async request({
+ request,
+ data,
+ onProgress,
+ signal,
+ contentType,
+ shouldRetryCredentials = true,
+ }: {
+ request: IT.PresignableRequest
+ data?: XMLHttpRequestBodyInit
+ onProgress?: IT.OnProgressFn
+ signal?: AbortSignal
+ contentType?: string
+ shouldRetryCredentials?: boolean
+ }): Promise<{ xhr: XMLHttpRequest; url: string }> {
+ // Wait for online before starting
+ await this.waitForOnline(signal)
+
+ // Check if aborted while waiting for online
+ if (signal?.aborted) {
+ throw new DOMException('Request aborted', 'AbortError')
+ }
+
+ try {
+ const { url } = await this.signRequest(request)
+
+ const xhr = await this.xhr({
+ url,
+ method: request.method,
+ data,
+ onProgress,
+ signal,
+ contentType,
+ })
+
+ return { xhr, url }
+ } catch (err: unknown) {
+ // NetworkError or errors with attached XHR (from onAfterResponse throws)
+ if (
+ err instanceof Error &&
+ 'request' in err &&
+ err.request instanceof XMLHttpRequest
+ ) {
+ const xhr = err.request as XMLHttpRequest
+ if (xhr.status === 0) {
+ throw new U.S3NetworkError(
+ 'Network error during S3 request',
+ 'NETWORK',
+ err,
+ )
+ }
+ // HTTP errors (non-2xx responses from NetworkError)
+ const parsedBody = this._parseErrorXml(
+ (name: string) => xhr.getResponseHeader(name),
+ xhr.responseText,
+ )
+ const serviceCode =
+ xhr.getResponseHeader('x-amz-error-code') ?? parsedBody.svcCode
+
+ // If expired token error and using getCredentials, clear cache and retry once
+ if (
+ shouldRetryCredentials &&
+ this.getCredentials != null &&
+ serviceCode != null &&
+ ['ExpiredToken', 'InvalidAccessKeyId'].includes(serviceCode)
+ ) {
+ this.clearCachedCredentials()
+
+ // Retry with fresh credentials
+ return this.request({
+ request,
+ data,
+ onProgress,
+ signal,
+ contentType,
+ shouldRetryCredentials: false, // prevent infinite recursion
+ })
+ }
+
+ throw new U.S3ServiceError(
+ `S3 returned ${xhr.status}${serviceCode ? ` – ${serviceCode}` : ''}`,
+ xhr.status,
+ serviceCode,
+ xhr.responseText,
+ )
+ }
+
+ throw err
+ }
+ }
+
+ /** Lists uploaded parts for a multipart upload. */
+ public override async listParts(
+ uploadId: string,
+ key: string,
+ ): Promise {
+ this._checkKey(key)
+ if (!uploadId) {
+ throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED)
+ }
+ const { xhr } = await this.request({
+ request: { method: 'GET', key, uploadId },
+ })
+
+ const parsed = U.parseXml(xhr.responseText) as Record
+ const result = (parsed.listPartsResult ||
+ parsed.ListPartsResult ||
+ parsed) as Record
+
+ if (result && typeof result === 'object') {
+ const parts = result.Part || result.part || []
+ const partsArray = Array.isArray(parts) ? parts : [parts]
+
+ return partsArray
+ .filter(
+ (p): p is Record =>
+ p != null &&
+ typeof p === 'object' &&
+ 'PartNumber' in p &&
+ 'ETag' in p,
+ )
+ .map((p) => ({
+ partNumber: parseInt(String(p.PartNumber), 10),
+ etag: U.sanitizeXmlETag(String(p.ETag)),
+ }))
+ }
+ return []
+ }
+
+ /** Completes a multipart upload by combining all uploaded parts. */
+ public override async completeMultipartUpload(
+ key: string,
+ uploadId: string,
+ parts: Array,
+ ) {
+ const xmlBody = this._buildCompleteMultipartUploadXml(parts)
+
+ const { xhr } = await this.request({
+ request: { method: 'POST', key, uploadId },
+ contentType: C.XML_CONTENT_TYPE,
+ data: xmlBody,
+ })
+
+ const parsed = U.parseXml(xhr.responseText)
+ if (parsed && typeof parsed === 'object') {
+ // Check for both cases (camelCase from our parser, PascalCase from S3)
+ const result =
+ parsed.completeMultipartUploadResult ||
+ parsed.CompleteMultipartUploadResult ||
+ parsed
+
+ if (result && typeof result === 'object') {
+ const r = result as Record
+
+ // S3 returns PascalCase (Location, Bucket, Key, ETag).
+ // Normalize to lowercase for our type interface.
+ const resultLocation = (r.Location || r.location) as string | undefined
+ const resultBucket = (r.Bucket || r.bucket) as string | undefined
+ const resultKey = (r.Key || r.key) as string | undefined
+ const rawEtag = (r.ETag || r.eTag || r.etag) as string | undefined
+
+ if (!resultLocation || !resultKey) {
+ throw new Error(
+ `${C.ERROR_PREFIX}CompleteMultipartUpload response missing Location or Key: ${JSON.stringify(r)}`,
+ )
+ }
+
+ const etag = rawEtag ? U.sanitizeXmlETag(rawEtag) : undefined
+
+ return {
+ location: resultLocation,
+ bucket: resultBucket,
+ key: resultKey,
+ etag,
+ }
+ }
+ }
+
+ throw new Error(
+ `${C.ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(
+ parsed,
+ )}`,
+ )
+ }
+
+ /** Aborts a multipart upload and removes all uploaded parts. */
+ public override async abortMultipartUpload(key: string, uploadId: string) {
+ this._checkKey(key)
+ if (!uploadId) {
+ throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED)
+ }
+
+ const { xhr } = await this.request({
+ request: { method: 'DELETE', key, uploadId },
+ })
+
+ const parsed = U.parseXml(xhr.responseText) as Record
+ if (
+ parsed &&
+ 'error' in parsed &&
+ typeof parsed.error === 'object' &&
+ parsed.error !== null &&
+ 'message' in parsed.error
+ ) {
+ throw new Error(
+ `${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(
+ parsed.error.message,
+ )}`,
+ )
+ }
+ }
+
+ private _buildCompleteMultipartUploadXml(
+ parts: Array,
+ ): string {
+ let xml = ''
+ for (const part of parts) {
+ xml += `${part.partNumber}${part.etag}`
+ }
+ xml += ''
+ return xml
+ }
+
+ /** Deletes an object from the bucket. Returns true on success. */
+ public override async deleteObject(key: string) {
+ const { xhr } = await this.request({
+ request: { method: 'DELETE', key },
+ })
+
+ if (xhr.status !== 200 && xhr.status !== 204) {
+ throw new Error(
+ `${C.ERROR_PREFIX}Failed to delete object. HTTP status: ${xhr.status}`,
+ )
+ }
+ }
+
+ /**
+ * Clears cached credentials.
+ * Call this method when you need to force a credential refresh on the next request.
+ */
+ public clearCachedCredentials(): void {
+ this.cachedCredentials = undefined
+ this.cachedCredentialsPromise = undefined
+ }
+
+ private _parseErrorXml(
+ getHeader: (name: string) => string | null,
+ body: string,
+ ): { svcCode?: string; errorMessage?: string } {
+ if (getHeader('content-type') !== 'application/xml') {
+ return {}
+ }
+ const parsedBody = U.parseXml(body)
+ if (
+ !parsedBody ||
+ typeof parsedBody !== 'object' ||
+ !('Error' in parsedBody) ||
+ !parsedBody.Error ||
+ typeof parsedBody.Error !== 'object'
+ ) {
+ return {}
+ }
+ const error = parsedBody.Error
+ return {
+ svcCode:
+ 'Code' in error && typeof error.Code === 'string'
+ ? error.Code
+ : undefined,
+ errorMessage:
+ 'Message' in error && typeof error.Message === 'string'
+ ? error.Message
+ : undefined,
+ }
+ }
+}
+
+export { S3mini }
+export default S3mini
diff --git a/packages/@uppy/aws-s3/src/s3-client/consts.ts b/packages/@uppy/aws-s3/src/s3-client/consts.ts
new file mode 100644
index 0000000000..65c8c55ecf
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/consts.ts
@@ -0,0 +1,43 @@
+// Constants
+export const AWS_ALGORITHM = 'AWS4-HMAC-SHA256'
+export const AWS_REQUEST_TYPE = 'aws4_request'
+export const S3_SERVICE = 's3'
+export const LIST_TYPE = '2'
+export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'
+export const DEFAULT_STREAM_CONTENT_TYPE = 'application/octet-stream'
+export const XML_CONTENT_TYPE = 'application/xml'
+export const JSON_CONTENT_TYPE = 'application/json'
+// List of keys that might contain sensitive information
+export const SENSITIVE_KEYS_REDACTED = new Set([
+ 'accessKeyId',
+ 'secretAccessKey',
+ 'sessionToken',
+ 'password',
+ 'token',
+] as const)
+export const IFHEADERS = new Set([
+ 'if-match',
+ 'if-none-match',
+ 'if-modified-since',
+ 'if-unmodified-since',
+] as const)
+export const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024
+
+// Headers
+export const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256'
+export const HEADER_AMZ_CHECKSUM_SHA256 = 'x-amz-checksum-sha256'
+export const HEADER_AMZ_DATE = 'x-amz-date'
+export const HEADER_HOST = 'host'
+export const HEADER_AUTHORIZATION = 'authorization'
+export const HEADER_CONTENT_TYPE = 'content-type'
+export const HEADER_CONTENT_LENGTH = 'content-length'
+export const HEADER_ETAG = 'etag'
+export const HEADER_LAST_MODIFIED = 'last-modified'
+
+// Error messages
+export const ERROR_PREFIX = '[s3mini] '
+export const ERROR_ENDPOINT_REQUIRED = `${ERROR_PREFIX}endpoint must be a non-empty string`
+export const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expected format: https://[:port][/base-path]`
+export const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string`
+export const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string`
+export const ERROR_DATA_BUFFER_REQUIRED = `${ERROR_PREFIX}data must be a Buffer or string`
diff --git a/packages/@uppy/aws-s3/src/s3-client/index.ts b/packages/@uppy/aws-s3/src/s3-client/index.ts
new file mode 100644
index 0000000000..cf6e14488b
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/index.ts
@@ -0,0 +1,8 @@
+// Export the S3 class as default export and named export
+export { S3mini } from './S3mini.js'
+// Re-export types
+export type {
+ ErrorWithCode,
+ S3Config,
+ UploadPart,
+} from './types.js'
diff --git a/packages/@uppy/aws-s3/src/s3-client/signer.ts b/packages/@uppy/aws-s3/src/s3-client/signer.ts
new file mode 100644
index 0000000000..a5c3f58e96
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/signer.ts
@@ -0,0 +1,149 @@
+/**
+ * AWS Signature Version 4 Pre-signed URL Generator for S3-compatible services.
+ * Generates pre-signed URLs with signature in the query string.
+ * @see https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
+ */
+
+import * as C from './consts.js'
+import type { PresignableRequest, presignedResponse } from './types.js'
+import * as U from './utils.js'
+
+export interface SignerConfig {
+ accessKeyId: string
+ secretAccessKey: string
+ sessionToken?: string
+ region: string
+ endpoint: string
+ service?: string
+}
+
+// Default expiry: 1 hour. This is how long the URL is valid before the request starts.
+const DEFAULT_EXPIRES_IN = 3600
+
+/**
+ * Creates a SigV4 pre-signed URL generator for S3 requests.
+ * Returns a function that generates pre-signed URLs.
+ */
+export function createSigV4Presigner(config: SignerConfig) {
+ const {
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ region,
+ endpoint,
+ service = C.S3_SERVICE,
+ } = config
+
+ let signingKeyDate: string | null = null
+ let signingKey: ArrayBuffer | null = null
+
+ async function getSignatureKey(dateStamp: string): Promise {
+ const kDate = await U.hmac(`AWS4${secretAccessKey}`, dateStamp)
+ const kRegion = await U.hmac(kDate, region)
+ const kService = await U.hmac(kRegion, service)
+ return U.hmac(kService, C.AWS_REQUEST_TYPE)
+ }
+
+ return async function presign(
+ request: PresignableRequest,
+ ): Promise {
+ const { method, key, expiresIn = DEFAULT_EXPIRES_IN } = request
+
+ // Build the URL - need to track encoded path separately because URL object decodes it
+ const url = new URL(endpoint)
+
+ // Normalize key: strip leading slashes to prevent double slashes when building path
+ // e.g., endpoint "/" + key "/file.txt" should become "/file.txt", not "//file.txt"
+ const normalizedKey = key ? key.replace(/^\/+/, '') : ''
+ const encodedKey = normalizedKey ? U.uriResourceEscape(normalizedKey) : ''
+
+ // Build the canonical path (must be encoded for signing)
+ let canonicalPath = url.pathname
+ if (encodedKey) {
+ canonicalPath = canonicalPath.endsWith('/')
+ ? `${canonicalPath}${encodedKey}`
+ : `${canonicalPath}/${encodedKey}`
+ }
+
+ const now = new Date()
+ const shortDate = now.toISOString().slice(0, 10).replace(/-/g, '')
+ const fullDatetime = `${shortDate}T${now.toISOString().slice(11, 19).replace(/:/g, '')}Z`
+ const credential = `${accessKeyId}/${shortDate}/${region}/${service}/${C.AWS_REQUEST_TYPE}`
+
+ // Build query parameters for pre-signed URL
+ url.searchParams.set('X-Amz-Algorithm', C.AWS_ALGORITHM)
+ // UNSIGNED_PAYLOAD tells AWS not to verify the body content.
+ // Required for pre-signed URLs since the body doesn't exist at signing time.
+ url.searchParams.set('X-Amz-Content-Sha256', C.UNSIGNED_PAYLOAD)
+ url.searchParams.set('X-Amz-Credential', credential)
+ url.searchParams.set('X-Amz-Date', fullDatetime)
+ url.searchParams.set('X-Amz-Expires', String(expiresIn))
+ url.searchParams.set('X-Amz-SignedHeaders', 'host')
+
+ // Add session token if present
+ if (sessionToken) {
+ url.searchParams.set('X-Amz-Security-Token', sessionToken)
+ }
+
+ // Add multipart-specific params
+ if ('uploadId' in request) {
+ url.searchParams.set('uploadId', request.uploadId)
+ }
+ if ('partNumber' in request) {
+ url.searchParams.set('partNumber', String(request.partNumber))
+ }
+
+ // For CreateMultipartUpload, add uploads param
+ if (method === 'POST' && !('uploadId' in request)) {
+ url.searchParams.set('uploads', '')
+ }
+
+ // Sort query params (AWS SigV4 requires ASCII byte ordering)
+ url.searchParams.sort()
+
+ // Build canonical query string (replace + with %20 as required for AWS)
+ const sortedParams = url.searchParams.toString().replace(/\+/g, '%20')
+
+ // Build canonical request
+ const canonicalHeaders = `host:${url.host}`
+ const signedHeaderNames = 'host'
+
+ const canonicalRequest = [
+ method,
+ canonicalPath, // Use the encoded path, not url.pathname which gets decoded
+ sortedParams,
+ canonicalHeaders,
+ '',
+ signedHeaderNames,
+ C.UNSIGNED_PAYLOAD,
+ ].join('\n')
+
+ // Build string to sign
+ const scope = `${shortDate}/${region}/${service}/${C.AWS_REQUEST_TYPE}`
+ const stringToSign = [
+ C.AWS_ALGORITHM,
+ fullDatetime,
+ scope,
+ U.hexFromBuffer(await U.sha256(canonicalRequest)),
+ ].join('\n')
+
+ // Get/cache signing key
+ if (shortDate !== signingKeyDate || !signingKey) {
+ signingKeyDate = shortDate
+ signingKey = await getSignatureKey(shortDate)
+ }
+
+ // Generate signature
+ const signature = U.hexFromBuffer(await U.hmac(signingKey, stringToSign))
+
+ // Build final URL with signature (use canonicalPath to preserve encoding)
+ const presignedUrl = `${url.origin}${canonicalPath}?${sortedParams}&X-Amz-Signature=${signature}`
+
+ return {
+ url: presignedUrl,
+ }
+ }
+}
+
+// Keep the old name as an alias for backward compatibility during migration
+export const createSigV4Signer = createSigV4Presigner
diff --git a/packages/@uppy/aws-s3/src/s3-client/types.ts b/packages/@uppy/aws-s3/src/s3-client/types.ts
new file mode 100644
index 0000000000..889dc465a0
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/types.ts
@@ -0,0 +1,133 @@
+export interface PresignableRequestBase {
+ key: string
+ expiresIn?: number
+}
+
+export interface PutObjectRequest extends PresignableRequestBase {
+ method: 'PUT'
+}
+export interface DeleteObjectRequest extends PresignableRequestBase {
+ method: 'DELETE'
+}
+export interface CreateMultipartUploadRequest extends PresignableRequestBase {
+ method: 'POST'
+}
+export interface CompleteMultipartUploadRequest extends PresignableRequestBase {
+ method: 'POST'
+ uploadId: string
+}
+export interface DeleteMultipartUploadRequest extends PresignableRequestBase {
+ method: 'DELETE'
+ uploadId: string
+}
+export interface ListPartsRequest extends PresignableRequestBase {
+ method: 'GET'
+ uploadId: string
+}
+export interface UploadPartRequest extends PresignableRequestBase {
+ method: 'PUT'
+ uploadId: string
+ partNumber: number
+}
+
+/** Request data to be pre-signed */
+export type PresignableRequest =
+ | PutObjectRequest
+ | DeleteObjectRequest
+ | CreateMultipartUploadRequest
+ | CompleteMultipartUploadRequest
+ | DeleteMultipartUploadRequest
+ | ListPartsRequest
+ | UploadPartRequest
+
+/** Response with the pre-signed URL */
+export type presignedResponse = {
+ url: string
+}
+
+/** Function that generates a pre-signed URL for a request */
+export type SignRequestFn = (
+ request: PresignableRequest,
+) => Promise
+
+/**
+ * Temporary security credentials from STS or similar service.
+ * These are used with getCredentials callback for client-side signing.
+ */
+export interface TemporaryCredentials {
+ accessKeyId: string
+ secretAccessKey: string
+ sessionToken: string
+ /** ISO 8601 date string when credentials expire */
+ expiration?: string
+}
+
+/**
+ * Response from getCredentials callback.
+ * Includes temporary credentials plus region info.
+ */
+export interface CredentialsResponse {
+ credentials: TemporaryCredentials
+ region: string
+}
+
+/** Function that retrieves temporary credentials */
+export type GetCredentialsFn = (options?: {
+ signal?: AbortSignal
+}) => CredentialsResponse | Promise
+
+/** Base configuration shared by both signing approaches */
+type S3ConfigBase = {
+ /** AWS region. Defaults to 'auto'. */
+ region?: string | undefined
+ /** Request size in bytes for multipart uploads. Defaults to 8MB. */
+ requestSizeInBytes?: number | undefined
+ /** Timeout in ms after which a request should be aborted. */
+ requestAbortTimeout?: number | undefined
+}
+
+/** Config when using signRequest callback (region optional) */
+type S3ConfigWithSignRequest = S3ConfigBase & {
+ /** Function to sign requests. Called for each S3 API request. */
+ signRequest: SignRequestFn
+}
+
+/** Config when using getCredentials callback (region required for signing) */
+type S3ConfigWithGetCredentials = Omit & {
+ /** Function to retrieve temporary credentials for client-side signing. */
+ getCredentials: GetCredentialsFn
+ /** AWS region. Required for signing with getCredentials. */
+ region?: string
+ /** Endpoint URL of the S3-compatible service (e.g., 'https://s3.amazonaws.com/bucket-name') */
+ endpoint: string
+}
+
+/** Configuration options for S3mini client */
+export type S3Config = S3ConfigWithSignRequest | S3ConfigWithGetCredentials
+
+export interface UploadPart {
+ partNumber: number
+ etag: string
+}
+
+export interface ErrorWithCode {
+ code?: string
+ cause?: { code?: string }
+}
+
+export type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE'
+
+export type XmlValue = string | XmlMap | boolean | number | null
+export interface XmlMap {
+ [key: string]: XmlValue | XmlValue[] // one or many children
+ [key: number]: XmlValue | XmlValue[] // allow numeric keys
+}
+
+/**
+ * Binary data types supported in browser environments.
+ * Use ArrayBuffer, Uint8Array, or Blob - Buffer is not available in browsers.
+ */
+export type BinaryData = ArrayBuffer | Uint8Array | Blob
+
+/** Progress callback for upload operations */
+export type OnProgressFn = (bytesUploaded: number, bytesTotal: number) => void
diff --git a/packages/@uppy/aws-s3/src/s3-client/utils.ts b/packages/@uppy/aws-s3/src/s3-client/utils.ts
new file mode 100644
index 0000000000..26beff568d
--- /dev/null
+++ b/packages/@uppy/aws-s3/src/s3-client/utils.ts
@@ -0,0 +1,175 @@
+import type { XmlMap, XmlValue } from './types.js'
+
+export const sanitizeXmlETag = (etag: string): string =>
+ etag.replace(/^("|")+|("|")+$/g, '')
+
+export const sanitizeETag = (etag: string | null): string | undefined =>
+ etag?.replace(/^"+|"+$/g, '')
+
+/** Strips query string and hash from a URL to derive the object location. */
+export function removeQueryString(urlString: string): string {
+ const urlObject = new URL(urlString)
+ urlObject.search = ''
+ urlObject.hash = ''
+ return urlObject.href
+}
+
+const textEncoder = new TextEncoder()
+const HEXS = '0123456789abcdef'
+
+/**
+ * Turn a raw ArrayBuffer into its hexadecimal representation.
+ * @param {ArrayBuffer} buffer The raw bytes.
+ * @returns {string} Hexadecimal string
+ */
+export const hexFromBuffer = (buffer: ArrayBuffer): string => {
+ const bytes = new Uint8Array(buffer)
+ let hex = ''
+ for (const byte of bytes) {
+ hex += HEXS[byte >> 4]! + HEXS[byte & 0x0f]!
+ }
+ return hex
+}
+
+/**
+ * Compute SHA-256 hash of arbitrary string data.
+ * @param {string} content The content to be hashed.
+ * @returns {ArrayBuffer} The raw hash
+ */
+export const sha256 = async (content: string): Promise => {
+ const data = textEncoder.encode(content)
+
+ return await globalThis.crypto.subtle.digest('SHA-256', data)
+}
+
+/**
+ * Compute HMAC-SHA-256 of arbitrary data.
+ * @param {string|ArrayBuffer} key The key used to sign the content.
+ * @param {string} content The content to be signed.
+ * @returns {ArrayBuffer} The raw signature
+ */
+export const hmac = async (
+ key: string | ArrayBuffer,
+ content: string,
+): Promise => {
+ const secret = await globalThis.crypto.subtle.importKey(
+ 'raw',
+ typeof key === 'string' ? textEncoder.encode(key) : key,
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ )
+ const data = textEncoder.encode(content)
+
+ return await globalThis.crypto.subtle.sign('HMAC', secret, data)
+}
+
+const entityMap = {
+ '"': '"',
+ ''': "'",
+ '<': '<',
+ '>': '>',
+ '&': '&',
+} as const
+
+const unescapeXml = (value: string): string =>
+ value.replaceAll(
+ /&(quot|apos|lt|gt|amp);/g,
+ (m) => entityMap[m as keyof typeof entityMap] ?? m,
+ )
+
+/**
+ * Parse a very small subset of XML into a JS structure.
+ *
+ * @param input raw XML string
+ * @returns string for leaf nodes, otherwise a map of children
+ */
+
+export const parseXml = (input: string): XmlValue => {
+ const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '')
+ const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*>([\s\S]*?)<\/\1>/gm
+ const result: XmlMap = {} // strong type, no `any`
+ let match: RegExpExecArray | null
+
+ // biome-ignore lint/suspicious/noAssignInExpressions: suppress
+ while ((match = RE_TAG.exec(xmlContent)) !== null) {
+ const tagName = match[1]
+ const innerContent = match[2]
+ const node: XmlValue = innerContent
+ ? parseXml(innerContent)
+ : unescapeXml(innerContent?.trim() || '')
+ if (!tagName) {
+ continue
+ }
+ const current = result[tagName]
+ if (current === undefined) {
+ // First occurrence
+ result[tagName] = node
+ } else if (Array.isArray(current)) {
+ // Already an array
+ current.push(node)
+ } else {
+ // Promote to array on the second occurrence
+ result[tagName] = [current, node]
+ }
+ }
+
+ // No child tags? — return the text, after entity decode
+ return Object.keys(result).length > 0
+ ? result
+ : unescapeXml(xmlContent.trim())
+}
+
+/**
+ * Encode a character as a URI percent-encoded hex value
+ * @param c Character to encode
+ * @returns Percent-encoded character
+ */
+const encodeAsHex = (c: string): string =>
+ `%${c.charCodeAt(0).toString(16).toUpperCase()}`
+
+/**
+ * Escape a URI string using percent encoding
+ * @param uriStr URI string to escape
+ * @returns Escaped URI string
+ */
+const uriEscape = (uriStr: string): string => {
+ return encodeURIComponent(uriStr).replace(/[!'()*]/g, encodeAsHex)
+}
+
+/**
+ * Escape a URI resource path while preserving forward slashes
+ * @param string URI path to escape
+ * @returns Escaped URI path
+ */
+export const uriResourceEscape = (string: string): string => {
+ return uriEscape(string).replaceAll('%2F', '/')
+}
+
+export class S3Error extends Error {
+ readonly code?: string
+ constructor(msg: string, code?: string, cause?: unknown) {
+ super(msg)
+ this.name = new.target.name // keeps instanceof usable
+ this.code = code
+ this.cause = cause
+ }
+}
+
+export class S3NetworkError extends S3Error {}
+export class S3ServiceError extends S3Error {
+ readonly status: number
+ readonly serviceCode?: string
+ body: string | undefined
+ constructor(
+ msg: string,
+ status: number,
+ serviceCode?: string,
+ body?: string,
+ ) {
+ super(msg, serviceCode)
+ this.status = status
+ this.serviceCode = serviceCode
+ this.body = body
+ }
+}
diff --git a/packages/@uppy/aws-s3/src/utils.ts b/packages/@uppy/aws-s3/src/utils.ts
index d5a2700b03..27804f894f 100644
--- a/packages/@uppy/aws-s3/src/utils.ts
+++ b/packages/@uppy/aws-s3/src/utils.ts
@@ -1,4 +1,3 @@
-import type { Body } from '@uppy/utils'
import { createAbortError } from '@uppy/utils'
import type { AwsS3Part } from './index.js'
@@ -22,9 +21,3 @@ export type UploadPartBytesResult = {
ETag: string
location?: string
}
-
-export interface AwsBody extends Body {
- location: string
- key: string
- bucket: string
-}
diff --git a/packages/@uppy/aws-s3/tests/index.test.ts b/packages/@uppy/aws-s3/tests/index.test.ts
new file mode 100644
index 0000000000..c52ebf176a
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/index.test.ts
@@ -0,0 +1,494 @@
+import { HttpResponse, http } from 'msw'
+import { setupServer } from 'msw/node'
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest'
+
+import 'whatwg-fetch'
+import Core, { type Meta, type UppyFile } from '@uppy/core'
+import AwsS3, { type AwsBody, type AwsS3Options } from '../src/index.js'
+
+const KB = 1024
+const MB = KB * KB
+
+// ---------------------------------------------------------------------------
+// Helpers for multipart upload tests
+// ---------------------------------------------------------------------------
+
+/** Minimal XML responses that S3 returns for multipart operations */
+const s3Responses = {
+ createMultipart: (uploadId: string, key: string) =>
+ `
+
+ ${uploadId}
+ ${key}
+ `,
+
+ uploadPart: (etag: string) => '',
+
+ listParts: (parts: { partNumber: number; etag: string }[]) =>
+ `
+
+ ${parts.map((p) => `${p.partNumber}${p.etag}`).join('')}
+ `,
+
+ completeMultipart: (location: string, key: string) =>
+ `
+
+ ${location}
+ ${key}
+ test-bucket
+ `,
+
+ abortMultipart: () => '',
+}
+
+const server = setupServer()
+const s3Url = 'https://test-bucket.s3.us-east-1.amazonaws.com/:key'
+
+/**
+ * Creates signRequest + MSW handler state for multipart upload tests.
+ */
+function createMultipartMocks(opts: { uploadId?: string; key?: string } = {}) {
+ const uploadId = opts.uploadId ?? 'test-upload-id'
+ const key = opts.key ?? 'test-key'
+
+ // signRequest encodes operation details in the URL for MSW routing
+ const signRequest = vi.fn().mockImplementation(async (req: any) => {
+ const params = new URLSearchParams()
+ if (req.uploadId) params.set('uploadId', req.uploadId)
+ if (req.partNumber) params.set('partNumber', String(req.partNumber))
+ params.set('method', req.method)
+ return {
+ url: `https://test-bucket.s3.us-east-1.amazonaws.com/${req.key || key}?${params}`,
+ }
+ })
+
+ const operations: string[] = []
+
+ const registerHandlers = ({
+ hangNonCreate = false,
+ listParts = [] as { partNumber: number; etag: string }[],
+ } = {}) => {
+ const maybeHang = () =>
+ hangNonCreate ? (new Promise(() => {}) as Promise) : null
+
+ server.use(
+ http.post(s3Url, ({ request }) => {
+ const hasUploadId = new URL(request.url).searchParams.has('uploadId')
+ if (!hasUploadId) {
+ operations.push('createMultipart')
+ return new HttpResponse(s3Responses.createMultipart(uploadId, key), {
+ status: 200,
+ headers: { 'Content-Type': 'application/xml' },
+ })
+ }
+
+ operations.push('completeMultipart')
+ const hung = maybeHang()
+ if (hung) return hung
+
+ return new HttpResponse(
+ s3Responses.completeMultipart(
+ `https://test-bucket.s3.amazonaws.com/${key}`,
+ key,
+ ),
+ { status: 200, headers: { 'Content-Type': 'application/xml' } },
+ )
+ }),
+ http.put(s3Url, () => {
+ operations.push('uploadPart')
+ const hung = maybeHang()
+ if (hung) return hung
+ return new HttpResponse('', {
+ status: 200,
+ headers: { ETag: '"etag-1"' },
+ })
+ }),
+ http.get(s3Url, ({ request }) => {
+ const hasUploadId = new URL(request.url).searchParams.has('uploadId')
+ if (!hasUploadId) {
+ return new HttpResponse('Not Found', { status: 404 })
+ }
+
+ operations.push('listParts')
+ const hung = maybeHang()
+ if (hung) return hung
+
+ return new HttpResponse(s3Responses.listParts(listParts), {
+ status: 200,
+ headers: { 'Content-Type': 'application/xml' },
+ })
+ }),
+ http.delete(s3Url, () => {
+ operations.push('abortMultipart')
+ return new HttpResponse('', { status: 204 })
+ }),
+ )
+ }
+
+ return { signRequest, operations, uploadId, key, registerHandlers }
+}
+
+describe('AwsS3', () => {
+ beforeAll(() => {
+ server.listen({ onUnhandledRequest: 'error' })
+ })
+
+ afterEach(() => {
+ server.resetHandlers()
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ it('Registers AwsS3 upload plugin', () => {
+ const core = new Core().use(AwsS3, {
+ region: 'us-east-1',
+ s3Endpoint: 'https://companion.example.com',
+ companionEndpoint: 'https://companion.example.com',
+ })
+
+ const pluginNames = core[Symbol.for('uppy test: getPlugins')](
+ 'uploader',
+ ).map((plugin: AwsS3) => plugin.constructor.name)
+ expect(pluginNames).toContain('AwsS3')
+ })
+
+ describe('configuration validation', () => {
+ it('throws if no signing method is provided', () => {
+ expect(() => {
+ const core = new Core()
+ // @ts-expect-error - testing runtime validation, so omit required options
+ core.use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ })
+ }).toThrow(
+ 'One of options `companionEndpoint`, `signRequest`, or `getCredentials` is required',
+ )
+ })
+
+ it('accepts endpoint option', () => {
+ const core = new Core()
+ core.use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ companionEndpoint: 'https://companion.example.com',
+ })
+ expect(core.getPlugin('AwsS3')).toBeDefined()
+ })
+
+ it('accepts signRequest option', () => {
+ const core = new Core()
+ core.use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest: vi.fn(),
+ })
+ expect(core.getPlugin('AwsS3')).toBeDefined()
+ })
+
+ it('accepts getCredentials option', () => {
+ const core = new Core()
+ core.use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ getCredentials: vi.fn(),
+ })
+ expect(core.getPlugin('AwsS3')).toBeDefined()
+ })
+ })
+
+ describe('shouldUseMultipart', () => {
+ const MULTIPART_THRESHOLD = 100 * MB
+
+ // Helper that creates a mock file without allocating memory
+ const createFile = (size: number): UppyFile =>
+ ({
+ name: 'test.dat',
+ size,
+ data: { size } as Blob,
+ }) as unknown as UppyFile
+
+ it('defaults to multipart for files > 100MB', () => {
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ companionEndpoint: 'https://companion.example.com',
+ })
+ const opts = core.getPlugin('AwsS3')!.opts as AwsS3Options
+ const shouldUseMultipart = opts.shouldUseMultipart as (
+ file: UppyFile,
+ ) => boolean
+
+ expect(shouldUseMultipart(createFile(MULTIPART_THRESHOLD + 1))).toBe(true)
+ expect(shouldUseMultipart(createFile(MULTIPART_THRESHOLD))).toBe(false)
+ expect(shouldUseMultipart(createFile(MULTIPART_THRESHOLD - 1))).toBe(
+ false,
+ )
+ expect(shouldUseMultipart(createFile(0))).toBe(false)
+ })
+
+ it('handles very large files', () => {
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ companionEndpoint: 'https://companion.example.com',
+ })
+ const opts = core.getPlugin('AwsS3')!.opts as AwsS3Options
+ const shouldUseMultipart = opts.shouldUseMultipart as (
+ file: UppyFile,
+ ) => boolean
+
+ expect(shouldUseMultipart(createFile(70 * 1024 * MB))).toBe(true) // 70GB
+ expect(shouldUseMultipart(createFile(400 * 1024 * MB))).toBe(true) // 400GB
+ })
+ })
+
+ describe('upload events', () => {
+ it('emits upload-start when upload begins', async () => {
+ const signRequest = vi.fn().mockRejectedValue(new Error('Test stop'))
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: false,
+ })
+
+ core.addFile({
+ source: 'test',
+ name: 'test.txt',
+ type: 'text/plain',
+ data: new File([new Uint8Array(1024)], 'test.txt'),
+ })
+
+ const uploadStartHandler = vi.fn()
+ core.on('upload-start', uploadStartHandler)
+
+ try {
+ await core.upload()
+ } catch {
+ // Expected
+ }
+
+ expect(uploadStartHandler).toHaveBeenCalledTimes(1)
+ })
+
+ it('emits upload-error on failure', async () => {
+ const signRequest = vi.fn().mockRejectedValue(new Error('Sign failed'))
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: false,
+ })
+
+ core.addFile({
+ source: 'test',
+ name: 'test.txt',
+ type: 'text/plain',
+ data: new File([new Uint8Array(1024)], 'test.txt'),
+ })
+
+ const uploadErrorHandler = vi.fn()
+ core.on('upload-error', uploadErrorHandler)
+
+ try {
+ await core.upload()
+ } catch {
+ // Expected
+ }
+
+ expect(uploadErrorHandler).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('abort', () => {
+ it('aborts when file is removed', async () => {
+ const signRequest = vi
+ .fn()
+ .mockImplementation(
+ () => new Promise((resolve) => setTimeout(resolve, 100)),
+ )
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: false,
+ })
+
+ core.addFile({
+ source: 'test',
+ name: 'test.txt',
+ type: 'text/plain',
+ data: new File([new Uint8Array(1024)], 'test.txt'),
+ })
+
+ const fileId = Object.keys(core.getState().files)[0]
+ const uploadPromise = core.upload()
+ setTimeout(() => core.removeFile(fileId), 10)
+
+ const result = await uploadPromise
+ // When a file is removed mid-upload, it should not appear in successful uploads
+ expect(result).toBeDefined()
+ expect(result?.successful).toHaveLength(0)
+ })
+
+ it('aborts when cancelAll is called', async () => {
+ const signRequest = vi
+ .fn()
+ .mockImplementation(
+ () => new Promise((resolve) => setTimeout(resolve, 100)),
+ )
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: false,
+ })
+
+ core.addFile({
+ source: 'test',
+ name: 'test.txt',
+ type: 'text/plain',
+ data: new File([new Uint8Array(1024)], 'test.txt'),
+ })
+
+ const uploadPromise = core.upload()
+ setTimeout(() => core.cancelAll(), 10)
+
+ const result = await uploadPromise
+ // When cancelAll is called, no files should complete successfully
+ expect(result).toBeDefined()
+ expect(result?.successful).toHaveLength(0)
+ })
+ })
+
+ describe('Golden Retriever resume state (s3Multipart)', () => {
+ it('persists s3Multipart on file state after creating multipart upload', async () => {
+ const { signRequest, uploadId, registerHandlers } = createMultipartMocks()
+ // After createMultipart succeeds, hang on subsequent requests so we can inspect state
+ registerHandlers({ hangNonCreate: true })
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: true,
+ })
+
+ const fileId = core.addFile({
+ source: 'test',
+ name: 'big.dat',
+ type: 'application/octet-stream',
+ data: new File([new Uint8Array(6 * MB)], 'big.dat'),
+ })
+
+ const uploadPromise = core.upload()
+
+ // Wait for createMultipart to complete and state to be persisted
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ const file = core.getFile(fileId)
+ expect(file.s3Multipart).toBeDefined()
+ expect(file.s3Multipart?.uploadId).toBe(uploadId)
+
+ // Clean up
+ core.cancelAll()
+ await uploadPromise
+ })
+
+ it('clears s3Multipart when upload is aborted via cancelAll', async () => {
+ const { signRequest, registerHandlers } = createMultipartMocks({
+ uploadId: 'cancel-test-id',
+ key: 'cancel-key',
+ })
+ registerHandlers({ hangNonCreate: true })
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: true,
+ })
+
+ const fileId = core.addFile({
+ source: 'test',
+ name: 'big.dat',
+ type: 'application/octet-stream',
+ data: new File([new Uint8Array(6 * MB)], 'big.dat'),
+ })
+
+ const uploadPromise = core.upload()
+
+ // Wait for createMultipart, then cancel
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ core.cancelAll()
+
+ await uploadPromise
+
+ const file = core.getFile(fileId)
+ // s3Multipart should be cleared so retries don't use a dead uploadId
+ expect(file?.s3Multipart).toBeUndefined()
+ })
+
+ it('uses persisted s3Multipart key for resume (listParts, not createMultipart)', async () => {
+ const persistedKey = 'persisted-object-key'
+ const persistedUploadId = 'persisted-upload-id'
+ const { signRequest, operations, registerHandlers } =
+ createMultipartMocks({
+ uploadId: persistedUploadId,
+ key: persistedKey,
+ })
+ registerHandlers()
+
+ const core = new Core().use(AwsS3, {
+ s3Endpoint: 'https://companion.example.com',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: false, // Would normally be simple upload
+ })
+
+ const fileId = core.addFile({
+ source: 'test',
+ name: 'big.dat',
+ type: 'application/octet-stream',
+ data: new File([new Uint8Array(6 * MB)], 'big.dat'),
+ })
+
+ // Simulate Golden Retriever restoring s3Multipart state
+ core.setFileState(fileId, {
+ s3Multipart: { uploadId: persistedUploadId, key: persistedKey },
+ })
+
+ const uploadPromise = core.upload()
+
+ // Wait for the resume flow to call listParts (via fetch), then cancel
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Should have resumed (listParts) instead of creating a new multipart upload
+ expect(operations).toContain('listParts')
+ expect(operations).not.toContain('createMultipart')
+
+ // The signRequest calls should use the persisted key, not a generated one
+ const signedKeys = signRequest.mock.calls.map((call: any) => call[0].key)
+ expect(signedKeys.every((k: string) => k === persistedKey)).toBe(true)
+
+ // Clean up
+ core.cancelAll()
+ await uploadPromise
+ })
+ })
+})
diff --git a/packages/@uppy/aws-s3/tests/s3-client/compose.minio.yaml b/packages/@uppy/aws-s3/tests/s3-client/compose.minio.yaml
new file mode 100644
index 0000000000..035e83df93
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/s3-client/compose.minio.yaml
@@ -0,0 +1,20 @@
+services:
+ minio:
+ image: quay.io/minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1
+ ports:
+ - 9002:9002
+ - 9003:9003
+ restart: always
+ environment:
+ MINIO_ROOT_USER: ${MINIO_ROOT_USER}
+ MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
+ MINIO_ADDRESS: ':9002'
+ MINIO_CONSOLE_ADDRESS: ':9003'
+ MC_HOST_local: 'http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@localhost:9002'
+ command: minio server /data
+ healthcheck:
+ test: mc ready local
+ start_period: 5s
+ interval: 30s
+ timeout: 3s
+ retries: 3
diff --git a/packages/@uppy/aws-s3/tests/s3-client/config.ts b/packages/@uppy/aws-s3/tests/s3-client/config.ts
new file mode 100644
index 0000000000..4f1a937539
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/s3-client/config.ts
@@ -0,0 +1,14 @@
+export const accessKeyId = 'stsuser'
+export const secretAccessKey = 'stspassword123'
+
+export function getConfig(env: Record) {
+ const config = env['VITE_MINIO_CONFIG']
+ if (!config) {
+ return undefined
+ }
+ const [rootAccessKeyId, rootSecretAccessKey, endpoint, region] =
+ config.split(',')
+ // Use stsuser credentials for all tests (readwrite policy is sufficient)
+ // Root credentials are kept for Docker container startup
+ return { endpoint, region, rootAccessKeyId, rootSecretAccessKey }
+}
diff --git a/packages/@uppy/aws-s3/tests/s3-client/docker.ts b/packages/@uppy/aws-s3/tests/s3-client/docker.ts
new file mode 100644
index 0000000000..93ddaf2763
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/s3-client/docker.ts
@@ -0,0 +1,89 @@
+import { exec, spawn } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const execAsync = promisify(exec)
+
+export async function getContainerName(serviceName: string) {
+ // Find container by service name using docker ps
+ const { stdout } = await execAsync(
+ `docker ps --filter "label=com.docker.compose.service=${serviceName}" --format "{{.Names}}"`,
+ )
+ const containerName = stdout.trim().split('\n')[0] // Get first matching container
+ if (!containerName) {
+ throw new Error(`No running container found for service: ${serviceName}`)
+ }
+ return containerName
+}
+
+export async function execDockerCommand(
+ containerName: string,
+ command: string,
+ timeoutMs = 10000,
+) {
+ // If it's a service name (like 'garage'), find the actual container name
+ let actualContainerName = containerName
+ if (['garage', 'minio', 'ceph'].includes(containerName)) {
+ try {
+ actualContainerName = await getContainerName(containerName)
+ // console.log(`Found container: ${actualContainerName} for service: ${containerName}`,)
+ } catch (_error) {
+ console.log(`Using container name as-is: ${containerName}`)
+ }
+ }
+
+ const dockerCommand = `docker exec ${actualContainerName} ${command}`
+
+ try {
+ // Add timeout to prevent hanging
+ const execPromise = execAsync(dockerCommand, { timeout: timeoutMs })
+ const { stdout, stderr } = await execPromise
+
+ if (stderr && !stderr.includes('WARNING')) {
+ console.warn(`Warning from docker exec: ${stderr}`)
+ }
+ return stdout.trim()
+ } catch (error) {
+ // Handle timeout specifically
+ if (error.killed && error.signal === 'SIGTERM') {
+ throw new Error(
+ `Command timed out after ${timeoutMs}ms: ${dockerCommand}`,
+ )
+ }
+
+ console.error(`Docker exec error details:`, {
+ command: dockerCommand,
+ message: error.message,
+ stderr: error.stderr,
+ stdout: error.stdout,
+ code: error.code,
+ killed: error.killed,
+ signal: error.signal,
+ })
+ throw error
+ }
+}
+
+async function run(cmd: string, args: string[], env?: Record) {
+ // console.log('running command:', cmd, args.join(' '))
+ return new Promise((res, rej) => {
+ const p = spawn(cmd, args, {
+ stdio: 'inherit',
+ env: { ...process.env, ...env },
+ })
+ p.on('close', (code) =>
+ code === 0
+ ? res()
+ : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`)),
+ )
+ p.on('error', (err) => rej(err))
+ })
+}
+
+export const composeUpWait = (file: string, env?: Record) =>
+ run(
+ 'docker',
+ ['compose', '-f', file, 'up', '-d', '--force-recreate', '--wait'],
+ env,
+ )
+export const composeDown = (file: string) =>
+ run('docker', ['compose', '-f', file, 'down', '--remove-orphans', '-v'])
diff --git a/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts b/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts
new file mode 100644
index 0000000000..190c13c15c
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts
@@ -0,0 +1,456 @@
+import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { S3mini } from '../../src/s3-client/S3mini.js'
+import { createSigV4Signer } from '../../src/s3-client/signer.js'
+import { randomBytes } from '../test-utils/browser-crypto.js'
+import { accessKeyId, getConfig, secretAccessKey } from './config.js'
+
+// @ts-expect-error todo
+const config = getConfig(import.meta.env)
+
+const suiteName = 'MinIO S3 client tests'
+
+if (config) {
+ const { endpoint, region } = config
+ const presigner = createSigV4Signer({
+ accessKeyId,
+ secretAccessKey,
+ region,
+ endpoint,
+ })
+
+ const s3client = new S3mini({
+ endpoint,
+ region,
+ signRequest: presigner,
+ })
+
+ const EIGHT_MB = 8 * 1024 * 1024
+ const key_bin = 'test-multipart.bin'
+
+ const large_buffer = randomBytes(EIGHT_MB * 3.2)
+ const content = 'some content'
+ const key = 'first-test-object.txt'
+ const key_list_parts = 'test-list-parts.bin'
+ const key_abort_multipart = 'test-abort-multipart.bin'
+
+ const FILE_KEYS = [key, key_bin, key_list_parts, key_abort_multipart]
+
+ beforeAll(async () => {
+ for (const key of FILE_KEYS) {
+ try {
+ await s3client.deleteObject(key)
+ } catch {
+ // intentionally ignore the errors as the objects don't exists. still feels hacky tbh
+ }
+ }
+ })
+
+ /** Create STS client using @aws-sdk/client-sts */
+ function createSTSClient({ endpoint, accessKeyId, secretAccessKey, region }) {
+ return new STSClient({
+ region,
+ endpoint,
+ credentials: { accessKeyId, secretAccessKey },
+ })
+ }
+
+ /** Get temporary credentials via AssumeRole */
+ async function assumeRole(
+ stsClient: STSClient,
+ { durationSeconds = 900 } = {},
+ ) {
+ const response = await stsClient.send(
+ new AssumeRoleCommand({
+ RoleArn: 'aws:iam::000000000000:role/test-role', // MinIO doesn't validate ARN
+ RoleSessionName: 'uppy-test',
+ DurationSeconds: durationSeconds,
+ }),
+ )
+ const creds = response.Credentials!
+ return {
+ AccessKeyId: creds.AccessKeyId,
+ SecretAccessKey: creds.SecretAccessKey,
+ SessionToken: creds.SessionToken,
+ Expiration: creds.Expiration?.toISOString() || creds.Expiration,
+ }
+ }
+
+ // ===== signRequest tests =====
+
+ describe(suiteName, () => {
+ it('simple putObject upload', async () => {
+ const key = 'presigned-test-file.txt'
+ const fileContents = new TextEncoder().encode(
+ 'Hello from pre-signed URL test.',
+ )
+
+ const result = await s3client.putObject(key, fileContents, 'text/plain')
+
+ expect(result.location).toBeDefined()
+ expect(result.location).toContain(key)
+ // location should be a clean URL without query string (no signing params)
+ expect(result.location).not.toContain('X-Amz-Signature')
+ expect(result.location).not.toContain('?')
+ })
+
+ it('multipart upload with signRequest', async () => {
+ const key = `presigned-multipart-${Date.now()}.bin`
+ const partSize = 5 * 1024 * 1024 // 5MB
+ const part = randomBytes(partSize)
+
+ const { uploadId } = await s3client.createMultipartUpload(
+ key,
+ 'application/octet-stream',
+ )
+ expect(uploadId).toBeDefined()
+
+ const partNumber = 1
+ const { etag } = await s3client.uploadPart(
+ key,
+ uploadId,
+ part,
+ partNumber,
+ )
+ expect(etag).toBeDefined()
+
+ const result = await s3client.completeMultipartUpload(key, uploadId, [
+ { etag, partNumber },
+ ])
+ expect(result.etag).toBeDefined()
+ expect(result.location).toBeDefined()
+ expect(result.location).toContain(key)
+ expect(result.key).toBe(key)
+
+ await s3client.deleteObject(key)
+ })
+
+ // ===== getCredentials tests (STS) =====
+
+ describe('getCredentials (STS)', () => {
+ const stsEndpoint = new URL(endpoint).origin
+
+ it('should upload using getCredentials callback', async () => {
+ const testKey = `sts-getcreds-${Date.now()}.txt`
+
+ const s3 = new S3mini({
+ endpoint,
+ region,
+ getCredentials: async () => {
+ const stsClient = createSTSClient({
+ endpoint: stsEndpoint,
+ accessKeyId,
+ secretAccessKey,
+ region,
+ })
+ const creds = await assumeRole(stsClient)
+ return {
+ credentials: {
+ accessKeyId: creds.AccessKeyId!,
+ secretAccessKey: creds.SecretAccessKey!,
+ sessionToken: creds.SessionToken!,
+ expiration: creds.Expiration as string,
+ },
+ bucket: new URL(endpoint).pathname.slice(1),
+ region,
+ }
+ },
+ })
+
+ try {
+ const result = await s3.putObject(testKey, 'Hello STS!', 'text/plain')
+ expect(result.location).toBeDefined()
+ expect(result.location).toContain(testKey)
+ expect(result.location).not.toContain('X-Amz-Signature')
+ expect(result.location).not.toContain('?')
+ } finally {
+ await s3.deleteObject(testKey)
+ }
+ })
+
+ it('should cache credentials across multiple requests', async () => {
+ let fetchCount = 0
+
+ const s3 = new S3mini({
+ endpoint,
+ region,
+ getCredentials: async () => {
+ fetchCount++
+ const stsClient = createSTSClient({
+ endpoint: stsEndpoint,
+ accessKeyId,
+ secretAccessKey,
+ region,
+ })
+ const creds = await assumeRole(stsClient)
+ return {
+ credentials: {
+ accessKeyId: creds.AccessKeyId!,
+ secretAccessKey: creds.SecretAccessKey!,
+ sessionToken: creds.SessionToken!,
+ expiration: creds.Expiration as string,
+ },
+ bucket: new URL(endpoint).pathname.slice(1),
+ region,
+ }
+ },
+ })
+
+ await s3.putObject(
+ `sts-cache-1-${Date.now()}.txt`,
+ 'File 1',
+ 'text/plain',
+ )
+ await s3.putObject(
+ `sts-cache-2-${Date.now()}.txt`,
+ 'File 2',
+ 'text/plain',
+ )
+ expect(fetchCount).toBe(1)
+ })
+
+ it('should perform multipart upload with getCredentials', async () => {
+ const s3 = new S3mini({
+ endpoint,
+ region,
+ getCredentials: async () => {
+ const stsClient = createSTSClient({
+ endpoint: stsEndpoint,
+ accessKeyId,
+ secretAccessKey,
+ region,
+ })
+ const creds = await assumeRole(stsClient)
+ return {
+ credentials: {
+ accessKeyId: creds.AccessKeyId!,
+ secretAccessKey: creds.SecretAccessKey!,
+ sessionToken: creds.SessionToken!,
+ expiration: creds.Expiration as string,
+ },
+ bucket: new URL(endpoint).pathname.slice(1),
+ region,
+ }
+ },
+ })
+
+ const key = `sts-multipart-${Date.now()}.bin`
+ const partSize = 5 * 1024 * 1024 // 5MB
+ const part = randomBytes(partSize)
+
+ const { uploadId } = await s3.createMultipartUpload(
+ key,
+ 'application/octet-stream',
+ )
+ expect(uploadId).toBeDefined()
+
+ const partNumber = 1
+ const { etag } = await s3.uploadPart(key, uploadId, part, partNumber)
+ expect(etag).toBeDefined()
+
+ const result = await s3.completeMultipartUpload(key, uploadId, [
+ { etag, partNumber },
+ ])
+ expect(result.etag).toBeDefined()
+ expect(result.location).toBeDefined()
+ expect(result.location).toContain(key)
+ expect(result.key).toBe(key)
+
+ await s3.deleteObject(key)
+ })
+ })
+
+ it('instantiates s3client', () => {
+ expect(s3client).toBeInstanceOf(S3mini) // ← updated expectation
+ })
+
+ // we don't need an explicit eTag method as we already get eTag in the putOject response
+ it('putObject uploads successfully and returns ETag', async () => {
+ const response = await s3client.putObject(key, content, 'text/plain')
+ expect(response.etag).toBeDefined()
+ await s3client.deleteObject(key)
+ })
+
+ it('putObject handles binary data', async () => {
+ const binaryData = new Uint8Array(6).fill(0xff)
+
+ const response = await s3client.putObject(
+ key,
+ binaryData,
+ 'application/octet-stream',
+ )
+
+ expect(response).toBeDefined()
+ expect(response.etag).toBeDefined()
+
+ // cleanup
+
+ await s3client.deleteObject(key)
+ })
+
+ // test createMultipartUpload
+
+ it('createMultipartUpload returns a valid uploadId', async () => {
+ const { uploadId } = await s3client.createMultipartUpload(
+ key_bin,
+ 'application/octet-stream',
+ )
+ expect(uploadId).toBeDefined()
+ expect(typeof uploadId).toBe('string')
+ expect(uploadId.length).toBeGreaterThan(0)
+
+ // cleanup
+ await s3client.abortMultipartUpload(key, uploadId)
+ })
+
+ // test uploadPart
+
+ it('uploadPart returns partNumber and Etag', async () => {
+ const partData = randomBytes(EIGHT_MB)
+
+ const { uploadId } = await s3client.createMultipartUpload(
+ key_bin,
+ 'application/octet-stream',
+ )
+
+ // upload part
+ const partResult = await s3client.uploadPart(
+ key_bin,
+ uploadId,
+ partData,
+ 1,
+ )
+
+ expect(partResult).toBeDefined()
+ expect(partResult.etag).toBeDefined()
+ expect(typeof partResult.etag).toBe('string')
+ expect(partResult.etag.length).toBe(32)
+
+ // cleanup
+ await s3client.abortMultipartUpload(key, uploadId)
+ })
+
+ // end to end multipart flow
+
+ it('completeMultipartUpload assembles parts correctly', async () => {
+ // key - key_bin
+ const partSize = EIGHT_MB
+ const totalParts = Math.ceil(large_buffer.byteLength / partSize)
+
+ const { uploadId } = await s3client.createMultipartUpload(
+ key_bin,
+ 'application/octet-stream',
+ )
+ expect(uploadId).toBeDefined()
+
+ // upload all parts
+ const uploadPromises: Promise<{ etag: string }>[] = []
+ for (let i = 0; i < totalParts; i++) {
+ const partBuffer = large_buffer.subarray(
+ i * partSize,
+ (i + 1) * partSize,
+ )
+ uploadPromises.push(
+ s3client.uploadPart(key_bin, uploadId, partBuffer, i + 1),
+ )
+ }
+ const uploadResponses = await Promise.all(uploadPromises)
+
+ // verify all parts uploaded succesfully
+
+ expect(uploadResponses.length).toBe(totalParts)
+
+ uploadResponses.forEach((response) => {
+ expect(response.etag).toBeDefined()
+ })
+
+ // create multipart upload
+
+ const parts = uploadResponses.map((response, index) => ({
+ partNumber: index + 1,
+ etag: response.etag,
+ }))
+
+ const completeResponse = await s3client.completeMultipartUpload(
+ key_bin,
+ uploadId,
+ parts,
+ )
+
+ expect(completeResponse).toBeDefined()
+ expect(typeof completeResponse).toBe('object')
+ expect(completeResponse.etag).toBeDefined()
+ expect(typeof completeResponse.etag).toBe('string')
+ expect(completeResponse.etag!.length).toBe(32 + 2)
+
+ // cleanup
+
+ await s3client.deleteObject(key_bin)
+ })
+
+ it('abortMultipartUpload cancels upload successfully', async () => {
+ // start upload
+ const { uploadId } = await s3client.createMultipartUpload(
+ key_abort_multipart,
+ 'application/octet-stream',
+ )
+
+ expect(uploadId).toBeDefined()
+
+ const partData = randomBytes(EIGHT_MB)
+ await s3client.uploadPart(key_abort_multipart, uploadId, partData, 1)
+
+ // abort
+ await s3client.abortMultipartUpload(key_abort_multipart, uploadId)
+ })
+
+ it('listParts returns uploaded parts correctly', async () => {
+ const partSize = EIGHT_MB
+
+ const { uploadId } = await s3client.createMultipartUpload(
+ key_list_parts,
+ 'application/octet-stream',
+ )
+ expect(uploadId).toBeDefined()
+
+ const part1Data = randomBytes(partSize)
+ const part2Data = randomBytes(partSize)
+
+ const part1Result = await s3client.uploadPart(
+ key_list_parts,
+ uploadId,
+ part1Data,
+ 1,
+ )
+ const part2Result = await s3client.uploadPart(
+ key_list_parts,
+ uploadId,
+ part2Data,
+ 2,
+ )
+
+ const parts = await s3client.listParts(uploadId, key_list_parts)
+
+ expect(parts).toBeInstanceOf(Array)
+ expect(parts.length).toBe(2)
+
+ // verify part 1
+ expect(parts[0].partNumber).toBe(1)
+ expect(parts[0].etag).toBe(part1Result.etag)
+
+ // verify part 2
+ expect(parts[1].partNumber).toBe(2)
+ expect(parts[1].etag).toBe(part2Result.etag)
+
+ // cleanup abort upload
+ await s3client.abortMultipartUpload(key, uploadId)
+ })
+ })
+} else {
+ console.warn(
+ 'Skipping MinIO S3 client tests: missing env variable VITE_MINIO_CONFIG',
+ )
+ describe.skip(suiteName, () => {
+ it('noop', () => {})
+ })
+}
diff --git a/packages/@uppy/aws-s3/tests/s3-client/setup.ts b/packages/@uppy/aws-s3/tests/s3-client/setup.ts
new file mode 100644
index 0000000000..2f52e586df
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/s3-client/setup.ts
@@ -0,0 +1,48 @@
+import path from 'node:path'
+import { getConfig } from './config'
+import { composeDown, composeUpWait, execDockerCommand } from './docker'
+
+const composeFile = path.join(__dirname, 'compose.minio.yaml')
+
+const config = getConfig(process.env)
+
+export async function setup() {
+ if (config == null) return
+
+ const { endpoint, rootAccessKeyId, rootSecretAccessKey } = config
+
+ console.log('⏫ starting minio image', composeFile)
+ process.env.MINIO_ROOT_USER = rootAccessKeyId
+ process.env.MINIO_ROOT_PASSWORD = rootSecretAccessKey
+ await composeUpWait(composeFile)
+ const bucketName = new URL(endpoint).pathname.split('/')[1]
+ await execDockerCommand(
+ 'minio',
+ `mc mb local/${bucketName} --ignore-existing`,
+ 30000,
+ )
+ // Create a user with readwrite policy for STS testing
+ // The root user cannot AssumeRole for itself - needs a regular user
+ try {
+ await execDockerCommand(
+ 'minio',
+ `mc admin user add local stsuser stspassword123`,
+ )
+ await execDockerCommand(
+ 'minio',
+ `mc admin policy attach local readwrite --user stsuser`,
+ )
+ } catch (err) {
+ // User may already exist, that's fine
+ console.log('STS user setup:', err.message || 'error occurred')
+ }
+ console.log(`✅ minio is ready`)
+}
+
+export async function teardown() {
+ if (config == null) return
+
+ console.log(`⏬ stopping minio image …`)
+ await composeDown(composeFile)
+ console.log(`✅ minio stopped and cleaned up`)
+}
diff --git a/packages/@uppy/aws-s3/tests/test-utils/browser-crypto.ts b/packages/@uppy/aws-s3/tests/test-utils/browser-crypto.ts
new file mode 100644
index 0000000000..2aa5aee660
--- /dev/null
+++ b/packages/@uppy/aws-s3/tests/test-utils/browser-crypto.ts
@@ -0,0 +1,17 @@
+/**
+ * Generate random bytes using Web Crypto API.
+ * Web Crypto has a 65536 byte limit per getRandomValues call,
+ * so we chunk large requests.
+ */
+export function randomBytes(size: number) {
+ const buffer = new Uint8Array(size)
+ const maxChunk = 65536 // Web Crypto API limit
+
+ for (let offset = 0; offset < size; offset += maxChunk) {
+ const chunkSize = Math.min(maxChunk, size - offset)
+ const chunk = buffer.subarray(offset, offset + chunkSize)
+ globalThis.crypto.getRandomValues(chunk)
+ }
+
+ return buffer
+}
diff --git a/packages/@uppy/aws-s3/vitest.config.ts b/packages/@uppy/aws-s3/vitest.config.ts
index 3544b2562c..37b4bd06ab 100644
--- a/packages/@uppy/aws-s3/vitest.config.ts
+++ b/packages/@uppy/aws-s3/vitest.config.ts
@@ -2,6 +2,27 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
- setupFiles: ['./test-setup.mjs'],
+ testTimeout: 120_000,
+ globalSetup: ['tests/s3-client/setup.ts'],
+ projects: [
+ {
+ test: {
+ name: 's3-jsdom',
+ include: ['**/*.test.ts'],
+ environment: 'jsdom',
+ },
+ },
+ {
+ test: {
+ name: 's3-browser',
+ include: ['**/minio.test.ts'],
+ browser: {
+ enabled: true,
+ provider: 'playwright',
+ instances: [{ browser: 'chromium' }],
+ },
+ },
+ },
+ ],
},
})
diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts
index 6f13065b25..5ced71801c 100644
--- a/packages/@uppy/core/src/Uppy.ts
+++ b/packages/@uppy/core/src/Uppy.ts
@@ -662,11 +662,12 @@ export class Uppy<
...files[fileID].progress,
...defaultProgress,
},
- // @ts-expect-error these typed are inserted
+ // @ts-expect-error these types are inserted
// into the namespace in their respective packages
- // but core isn't ware of those
+ // but core isn't aware of those
tus: undefined,
transloadit: undefined,
+ s3Multipart: undefined,
}
})
diff --git a/packages/@uppy/utils/src/fetcher.ts b/packages/@uppy/utils/src/fetcher.ts
index 87e0540dba..c550aecef9 100644
--- a/packages/@uppy/utils/src/fetcher.ts
+++ b/packages/@uppy/utils/src/fetcher.ts
@@ -61,7 +61,7 @@ export function fetcher(
options: FetcherOptions = {},
): Promise {
const {
- body = null,
+ body,
headers = {},
method = 'GET',
onBeforeRequest = noop,
diff --git a/private/dev/Dashboard.js b/private/dev/Dashboard.js
index f3b7ffdd51..16b73f68af 100644
--- a/private/dev/Dashboard.js
+++ b/private/dev/Dashboard.js
@@ -208,13 +208,13 @@ export default () => {
break
case 's3':
uppyDashboard.use(AwsS3, {
- endpoint: COMPANION_URL,
+ companionEndpoint: COMPANION_URL,
shouldUseMultipart: false,
})
break
case 's3-multipart':
uppyDashboard.use(AwsS3, {
- endpoint: COMPANION_URL,
+ companionEndpoint: COMPANION_URL,
shouldUseMultipart: true,
})
break
diff --git a/turbo.json b/turbo.json
index 687e96dd81..a3386bfe39 100644
--- a/turbo.json
+++ b/turbo.json
@@ -41,7 +41,8 @@
},
"test": {
"dependsOn": ["^test"],
- "cache": false
+ "cache": false,
+ "passThroughEnv": ["VITE_MINIO_CONFIG"]
},
"test:e2e": {
"dependsOn": ["^test:e2e"],
diff --git a/yarn.lock b/yarn.lock
index 38952d48cb..c66547166e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -831,6 +831,52 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/client-sso@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/client-sso@npm:3.953.0"
+ dependencies:
+ "@aws-crypto/sha256-browser": "npm:5.2.0"
+ "@aws-crypto/sha256-js": "npm:5.2.0"
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/middleware-host-header": "npm:3.953.0"
+ "@aws-sdk/middleware-logger": "npm:3.953.0"
+ "@aws-sdk/middleware-recursion-detection": "npm:3.953.0"
+ "@aws-sdk/middleware-user-agent": "npm:3.953.0"
+ "@aws-sdk/region-config-resolver": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@aws-sdk/util-endpoints": "npm:3.953.0"
+ "@aws-sdk/util-user-agent-browser": "npm:3.953.0"
+ "@aws-sdk/util-user-agent-node": "npm:3.953.0"
+ "@smithy/config-resolver": "npm:^4.4.4"
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/fetch-http-handler": "npm:^5.3.7"
+ "@smithy/hash-node": "npm:^4.2.6"
+ "@smithy/invalid-dependency": "npm:^4.2.6"
+ "@smithy/middleware-content-length": "npm:^4.2.6"
+ "@smithy/middleware-endpoint": "npm:^4.3.15"
+ "@smithy/middleware-retry": "npm:^4.4.15"
+ "@smithy/middleware-serde": "npm:^4.2.7"
+ "@smithy/middleware-stack": "npm:^4.2.6"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/node-http-handler": "npm:^4.4.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/smithy-client": "npm:^4.10.0"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/url-parser": "npm:^4.2.6"
+ "@smithy/util-base64": "npm:^4.3.0"
+ "@smithy/util-body-length-browser": "npm:^4.2.0"
+ "@smithy/util-body-length-node": "npm:^4.2.1"
+ "@smithy/util-defaults-mode-browser": "npm:^4.3.14"
+ "@smithy/util-defaults-mode-node": "npm:^4.2.17"
+ "@smithy/util-endpoints": "npm:^3.2.6"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-retry": "npm:^4.2.6"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/f7b21d79a608e9ab72b6ae1bbc8adb05a0659825d0f5ca2548368cff911c94502209fdebd0637306ec8923cb0ecbf21f238ee69191363bd416b36252343966a6
+ languageName: node
+ linkType: hard
+
"@aws-sdk/client-sts@npm:3.600.0, @aws-sdk/client-sts@npm:^3.338.0":
version: 3.600.0
resolution: "@aws-sdk/client-sts@npm:3.600.0"
@@ -879,6 +925,53 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/client-sts@npm:^3.362.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/client-sts@npm:3.953.0"
+ dependencies:
+ "@aws-crypto/sha256-browser": "npm:5.2.0"
+ "@aws-crypto/sha256-js": "npm:5.2.0"
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/credential-provider-node": "npm:3.953.0"
+ "@aws-sdk/middleware-host-header": "npm:3.953.0"
+ "@aws-sdk/middleware-logger": "npm:3.953.0"
+ "@aws-sdk/middleware-recursion-detection": "npm:3.953.0"
+ "@aws-sdk/middleware-user-agent": "npm:3.953.0"
+ "@aws-sdk/region-config-resolver": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@aws-sdk/util-endpoints": "npm:3.953.0"
+ "@aws-sdk/util-user-agent-browser": "npm:3.953.0"
+ "@aws-sdk/util-user-agent-node": "npm:3.953.0"
+ "@smithy/config-resolver": "npm:^4.4.4"
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/fetch-http-handler": "npm:^5.3.7"
+ "@smithy/hash-node": "npm:^4.2.6"
+ "@smithy/invalid-dependency": "npm:^4.2.6"
+ "@smithy/middleware-content-length": "npm:^4.2.6"
+ "@smithy/middleware-endpoint": "npm:^4.3.15"
+ "@smithy/middleware-retry": "npm:^4.4.15"
+ "@smithy/middleware-serde": "npm:^4.2.7"
+ "@smithy/middleware-stack": "npm:^4.2.6"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/node-http-handler": "npm:^4.4.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/smithy-client": "npm:^4.10.0"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/url-parser": "npm:^4.2.6"
+ "@smithy/util-base64": "npm:^4.3.0"
+ "@smithy/util-body-length-browser": "npm:^4.2.0"
+ "@smithy/util-body-length-node": "npm:^4.2.1"
+ "@smithy/util-defaults-mode-browser": "npm:^4.3.14"
+ "@smithy/util-defaults-mode-node": "npm:^4.2.17"
+ "@smithy/util-endpoints": "npm:^3.2.6"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-retry": "npm:^4.2.6"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/b41dc2190fec8ccd2bcae77a2cfc1ed9d20e9586c4bdfa60274fa1f4e11272bc78b01c493d25f492d44c132c20a8af70d4faa36f49f3559f2b6f7dc7101f6d0d
+ languageName: node
+ linkType: hard
+
"@aws-sdk/core@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/core@npm:3.598.0"
@@ -915,6 +1008,27 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/core@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/core@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@aws-sdk/xml-builder": "npm:3.953.0"
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/signature-v4": "npm:^5.3.6"
+ "@smithy/smithy-client": "npm:^4.10.0"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-base64": "npm:^4.3.0"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/949798b03a10e309bf7dc6bfc2bfb4aabaddb6dee87c85e06c811b933af1cd83ec989be96c8066201663d50b48c40a82db9f543ababc1b43f885d6ce171fe7d5
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-env@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/credential-provider-env@npm:3.598.0"
@@ -940,6 +1054,19 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-env@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-env@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/82c006cf8944476fe857b8b61533f25cca3ffbe9dfe236cc26dc88c4d54a2d22b23cb16faddfa130b1f5bd5ed0c0ba8bca04c67e5bff11d278f772057b6c4b4c
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-http@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/credential-provider-http@npm:3.598.0"
@@ -975,6 +1102,24 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-http@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-http@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/fetch-http-handler": "npm:^5.3.7"
+ "@smithy/node-http-handler": "npm:^4.4.6"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/smithy-client": "npm:^4.10.0"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-stream": "npm:^4.5.7"
+ tslib: "npm:^2.6.2"
+ checksum: 10/0a4c0092929caac95fd2c003c504bba26e5aa884252a98de7a03f18cb1ef69e944f4997628f7409ed7fb364d412a5d8c64ef8e9e8fc057612cc10314e66b45a2
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-ini@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/credential-provider-ini@npm:3.598.0"
@@ -1017,6 +1162,44 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-ini@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-ini@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/credential-provider-env": "npm:3.953.0"
+ "@aws-sdk/credential-provider-http": "npm:3.953.0"
+ "@aws-sdk/credential-provider-login": "npm:3.953.0"
+ "@aws-sdk/credential-provider-process": "npm:3.953.0"
+ "@aws-sdk/credential-provider-sso": "npm:3.953.0"
+ "@aws-sdk/credential-provider-web-identity": "npm:3.953.0"
+ "@aws-sdk/nested-clients": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/credential-provider-imds": "npm:^4.2.6"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/e416b9e2d2da37f945450282a78e9b477daf6e3dafcb7c61b25897c4438ce7b4675ea6cc9292bb120a4ba16a0105ad51f62d9af0b00007275a3c14a3d3f57890
+ languageName: node
+ linkType: hard
+
+"@aws-sdk/credential-provider-login@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-login@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/nested-clients": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/0642d2029f8dbadd95e1678afd4a9a99735028b06934cd783b91acad86e29fb920bd75396f95b6a581ad8cb4cee36444b3aef37d8721ecdd7d47147f7abdd182
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-node@npm:3.600.0":
version: 3.600.0
resolution: "@aws-sdk/credential-provider-node@npm:3.600.0"
@@ -1057,6 +1240,26 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-node@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-node@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/credential-provider-env": "npm:3.953.0"
+ "@aws-sdk/credential-provider-http": "npm:3.953.0"
+ "@aws-sdk/credential-provider-ini": "npm:3.953.0"
+ "@aws-sdk/credential-provider-process": "npm:3.953.0"
+ "@aws-sdk/credential-provider-sso": "npm:3.953.0"
+ "@aws-sdk/credential-provider-web-identity": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/credential-provider-imds": "npm:^4.2.6"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/a842444887a1ea8ccf6282b34a150f22c75274650906bfde2cae76e672fd3898c0504c8add5459c0cd8a1113c397791d9dad77dce427282429c998435a4629c4
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-process@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/credential-provider-process@npm:3.598.0"
@@ -1084,6 +1287,20 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-process@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-process@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/39ff89306d9a55becec4d5fa8dc82420c3cd5ff5079fb91ec2b8812cf048d6063474215dd2a205e8f02f85815b17b38f89ed7f5a1b16023bfcd56abeb325dd55
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-sso@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/credential-provider-sso@npm:3.598.0"
@@ -1115,6 +1332,22 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-sso@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-sso@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/client-sso": "npm:3.953.0"
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/token-providers": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/4c95ce8f60983d31a7454ee0a201dea4c0e20e9e2ce4ef1eebf6e096f4cb99991811260a47cb2c9ed2a0b63c5a1663f7b4c7c9d19918b2494239e54efab8477f
+ languageName: node
+ linkType: hard
+
"@aws-sdk/credential-provider-web-identity@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/credential-provider-web-identity@npm:3.598.0"
@@ -1144,6 +1377,21 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/credential-provider-web-identity@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/credential-provider-web-identity@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/nested-clients": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/84d94fb4ecfa12b882f49a409b9cdc3c17de08f94b76376b64690198bc61e3ec2d0e85d2e2518f3bb99ce7cb43fc32202f6c193f89b0df9c03fc586f3e6df560
+ languageName: node
+ linkType: hard
+
"@aws-sdk/lib-storage@npm:^3.338.0":
version: 3.600.0
resolution: "@aws-sdk/lib-storage@npm:3.600.0"
@@ -1276,6 +1524,18 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/middleware-host-header@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/middleware-host-header@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/c78936083a336310814d7e64ffd8d6dbf26b85abd4f8bcf90b75a1ad629704e390d75493339082d38a96c24a9ab9b1ad6e8e22d87035486547a3ae32fbdc13e4
+ languageName: node
+ linkType: hard
+
"@aws-sdk/middleware-location-constraint@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/middleware-location-constraint@npm:3.598.0"
@@ -1320,6 +1580,17 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/middleware-logger@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/middleware-logger@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/3f02f3a954af46a94246e6bdbf5aca351bd61d6fc0c04f83505be353da128af26ac4ef9190450766bb6ea298213056c5dbc64e641a366a318e394e7272edf4b0
+ languageName: node
+ linkType: hard
+
"@aws-sdk/middleware-recursion-detection@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/middleware-recursion-detection@npm:3.598.0"
@@ -1345,6 +1616,19 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/middleware-recursion-detection@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/middleware-recursion-detection@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@aws/lambda-invoke-store": "npm:^0.2.2"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/02802b74a08cbf8731db588c01645acf0bd9e014f3e68e7690fc28d62348a498da76f68a47a763b8d7f4a7e407d23c7c9e6f98017f85dde853b481421556a451
+ languageName: node
+ linkType: hard
+
"@aws-sdk/middleware-sdk-s3@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/middleware-sdk-s3@npm:3.598.0"
@@ -1449,6 +1733,21 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/middleware-user-agent@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/middleware-user-agent@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@aws-sdk/util-endpoints": "npm:3.953.0"
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/2dfccca22e3829c17b609cca236fe3be2e2bc234c438d0beda8d87cd7614a8b31825a63337ce26b565d52c3905e4c6e914d4f83156654bededb6f175fa9880ac
+ languageName: node
+ linkType: hard
+
"@aws-sdk/nested-clients@npm:3.896.0":
version: 3.896.0
resolution: "@aws-sdk/nested-clients@npm:3.896.0"
@@ -1495,6 +1794,52 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/nested-clients@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/nested-clients@npm:3.953.0"
+ dependencies:
+ "@aws-crypto/sha256-browser": "npm:5.2.0"
+ "@aws-crypto/sha256-js": "npm:5.2.0"
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/middleware-host-header": "npm:3.953.0"
+ "@aws-sdk/middleware-logger": "npm:3.953.0"
+ "@aws-sdk/middleware-recursion-detection": "npm:3.953.0"
+ "@aws-sdk/middleware-user-agent": "npm:3.953.0"
+ "@aws-sdk/region-config-resolver": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@aws-sdk/util-endpoints": "npm:3.953.0"
+ "@aws-sdk/util-user-agent-browser": "npm:3.953.0"
+ "@aws-sdk/util-user-agent-node": "npm:3.953.0"
+ "@smithy/config-resolver": "npm:^4.4.4"
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/fetch-http-handler": "npm:^5.3.7"
+ "@smithy/hash-node": "npm:^4.2.6"
+ "@smithy/invalid-dependency": "npm:^4.2.6"
+ "@smithy/middleware-content-length": "npm:^4.2.6"
+ "@smithy/middleware-endpoint": "npm:^4.3.15"
+ "@smithy/middleware-retry": "npm:^4.4.15"
+ "@smithy/middleware-serde": "npm:^4.2.7"
+ "@smithy/middleware-stack": "npm:^4.2.6"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/node-http-handler": "npm:^4.4.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/smithy-client": "npm:^4.10.0"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/url-parser": "npm:^4.2.6"
+ "@smithy/util-base64": "npm:^4.3.0"
+ "@smithy/util-body-length-browser": "npm:^4.2.0"
+ "@smithy/util-body-length-node": "npm:^4.2.1"
+ "@smithy/util-defaults-mode-browser": "npm:^4.3.14"
+ "@smithy/util-defaults-mode-node": "npm:^4.2.17"
+ "@smithy/util-endpoints": "npm:^3.2.6"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-retry": "npm:^4.2.6"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/35baacf1c2513515f0ae6b1f3772401f43a24eaac08c1614680711aae23418ba1243a3144419a4a0662e929d2411ac39f833984794b92fdc4aa8238ee2ce7020
+ languageName: node
+ linkType: hard
+
"@aws-sdk/region-config-resolver@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/region-config-resolver@npm:3.598.0"
@@ -1523,6 +1868,19 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/region-config-resolver@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/region-config-resolver@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/config-resolver": "npm:^4.4.4"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/b11d9507df620afceec0e1a6c39225eae0494aac602c0113a895cff8cec2a6db36523ad8eff4b673f1d15a6e5bc9c971ba0c64d46ff5a59df5afa2b198e3f969
+ languageName: node
+ linkType: hard
+
"@aws-sdk/s3-presigned-post@npm:^3.338.0":
version: 3.600.0
resolution: "@aws-sdk/s3-presigned-post@npm:3.600.0"
@@ -1614,6 +1972,21 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/token-providers@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/token-providers@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/core": "npm:3.953.0"
+ "@aws-sdk/nested-clients": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/1ef0b8d8da84f815ef8ce9789fb894d512d970b91a61317422829ea82faebfc0d7270581306554e19b9f70e39730e56a438c25bffd29cdb4bdbecbb821caa53c
+ languageName: node
+ linkType: hard
+
"@aws-sdk/types@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/types@npm:3.598.0"
@@ -1634,6 +2007,16 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/types@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/types@npm:3.953.0"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/959bab8a9cd280c0824265dce0892796326896df5407d4cf482791a76033182fe10b8080909965610cb56ff6c6117413e9920e864f519e85ce2c5a0041fd9cf1
+ languageName: node
+ linkType: hard
+
"@aws-sdk/util-arn-parser@npm:3.568.0":
version: 3.568.0
resolution: "@aws-sdk/util-arn-parser@npm:3.568.0"
@@ -1677,6 +2060,19 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/util-endpoints@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/util-endpoints@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/url-parser": "npm:^4.2.6"
+ "@smithy/util-endpoints": "npm:^3.2.6"
+ tslib: "npm:^2.6.2"
+ checksum: 10/d1aebcf2a32d0c40c28462fcfaa34868fb09f7560ce622bda6f5194039783be218134119e928e4162e2820abfa7cf2777b730fbca6b79a64cb98f4763dc113eb
+ languageName: node
+ linkType: hard
+
"@aws-sdk/util-format-url@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/util-format-url@npm:3.598.0"
@@ -1734,6 +2130,18 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/util-user-agent-browser@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/util-user-agent-browser@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/types": "npm:^4.10.0"
+ bowser: "npm:^2.11.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/735ea58521b095ac5546e8cab4eadbb944207a619ed47a5ef962b6501bfc8dc296d24e3c7a2c27a0a44537966a49bf61467ed4bf5578eb8ba07fa6f343e99063
+ languageName: node
+ linkType: hard
+
"@aws-sdk/util-user-agent-node@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/util-user-agent-node@npm:3.598.0"
@@ -1769,6 +2177,24 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/util-user-agent-node@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/util-user-agent-node@npm:3.953.0"
+ dependencies:
+ "@aws-sdk/middleware-user-agent": "npm:3.953.0"
+ "@aws-sdk/types": "npm:3.953.0"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ peerDependencies:
+ aws-crt: ">=1.0.0"
+ peerDependenciesMeta:
+ aws-crt:
+ optional: true
+ checksum: 10/8d21a0106214469f1ce72b33cbdf3fef2dcdf36505874b9c0a999bee1b63390190e82f496c5b1e9cd0503304cee9ec58c491e5663cc38b3965284b74e9e0757f
+ languageName: node
+ linkType: hard
+
"@aws-sdk/xml-builder@npm:3.598.0":
version: 3.598.0
resolution: "@aws-sdk/xml-builder@npm:3.598.0"
@@ -1790,6 +2216,17 @@ __metadata:
languageName: node
linkType: hard
+"@aws-sdk/xml-builder@npm:3.953.0":
+ version: 3.953.0
+ resolution: "@aws-sdk/xml-builder@npm:3.953.0"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ fast-xml-parser: "npm:5.2.5"
+ tslib: "npm:^2.6.2"
+ checksum: 10/7ab2023a397373f4c52501e178d054d7637df78a90dc5ee430af6448e005f601f230aa284f8b41371a2db5a382c568a076968437bf1ec4b0122f546ea1e14b7e
+ languageName: node
+ linkType: hard
+
"@aws/lambda-invoke-store@npm:^0.0.1":
version: 0.0.1
resolution: "@aws/lambda-invoke-store@npm:0.0.1"
@@ -1797,6 +2234,13 @@ __metadata:
languageName: node
linkType: hard
+"@aws/lambda-invoke-store@npm:^0.2.2":
+ version: 0.2.2
+ resolution: "@aws/lambda-invoke-store@npm:0.2.2"
+ checksum: 10/18cd0cec90d9d865c9089218ef2220b0a7302a860c9a3f808b101386f569abc5ee11eb98a36947bed280a63308dd5df23c39e7b07fe9ac4f4ffcd0c4dce537c4
+ languageName: node
+ linkType: hard
+
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/code-frame@npm:7.27.1"
@@ -6047,6 +6491,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/abort-controller@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/abort-controller@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/ef683909987f6225e43de1a256126f20d0ed213ae4f59edb2d9bc4c0c0d3d809dc66d3313afc3603aae81b842a23aa47f6e19d6439f009d7898db8c4206a7588
+ languageName: node
+ linkType: hard
+
"@smithy/chunked-blob-reader-native@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/chunked-blob-reader-native@npm:3.0.0"
@@ -6111,6 +6565,20 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/config-resolver@npm:^4.4.4":
+ version: 4.4.4
+ resolution: "@smithy/config-resolver@npm:4.4.4"
+ dependencies:
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-config-provider": "npm:^4.2.0"
+ "@smithy/util-endpoints": "npm:^3.2.6"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ tslib: "npm:^2.6.2"
+ checksum: 10/61f25f55e81233b7cd7cb3a123bb16052640de63ef6badab399b0f95fe3846e36b93181c6d80707265c662f0949a2f7f12b57e85580ed3a0e43403b19c87b36f
+ languageName: node
+ linkType: hard
+
"@smithy/core@npm:^2.2.1":
version: 2.2.3
resolution: "@smithy/core@npm:2.2.3"
@@ -6145,6 +6613,24 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/core@npm:^3.19.0":
+ version: 3.19.0
+ resolution: "@smithy/core@npm:3.19.0"
+ dependencies:
+ "@smithy/middleware-serde": "npm:^4.2.7"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-base64": "npm:^4.3.0"
+ "@smithy/util-body-length-browser": "npm:^4.2.0"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-stream": "npm:^4.5.7"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ "@smithy/uuid": "npm:^1.1.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/feedf2138fd1b63108603d30969d9bc9053f117086f690f26049193381af8ba78a48fc90d8941c98c3564c2f45dc8ad57fb6bae9cff0a146f6c82d7b376def02
+ languageName: node
+ linkType: hard
+
"@smithy/credential-provider-imds@npm:^3.1.1, @smithy/credential-provider-imds@npm:^3.1.2":
version: 3.1.2
resolution: "@smithy/credential-provider-imds@npm:3.1.2"
@@ -6171,6 +6657,19 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/credential-provider-imds@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/credential-provider-imds@npm:4.2.6"
+ dependencies:
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/url-parser": "npm:^4.2.6"
+ tslib: "npm:^2.6.2"
+ checksum: 10/ac0fe1e1bb75b82287bdd6869f072ce9928baaf27d61cba7c881968161dd94fd9c287f208ac7b77c83a7983bde9cfffd5bcc32ba788dbd6903aa3702aac1618a
+ languageName: node
+ linkType: hard
+
"@smithy/eventstream-codec@npm:^3.1.1":
version: 3.1.1
resolution: "@smithy/eventstream-codec@npm:3.1.1"
@@ -6307,6 +6806,19 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/fetch-http-handler@npm:^5.3.7":
+ version: 5.3.7
+ resolution: "@smithy/fetch-http-handler@npm:5.3.7"
+ dependencies:
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/querystring-builder": "npm:^4.2.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-base64": "npm:^4.3.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/ad45a381f37dcad2e7df47b596569bd182e96f73ca329726bb10209eff7e92efb1d50e3a0f14ffc15c7d6e0afbc8cc267c2151e6b2f99df33fa1dbc95441c0a9
+ languageName: node
+ linkType: hard
+
"@smithy/hash-blob-browser@npm:^3.1.0":
version: 3.1.1
resolution: "@smithy/hash-blob-browser@npm:3.1.1"
@@ -6355,6 +6867,18 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/hash-node@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/hash-node@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-buffer-from": "npm:^4.2.0"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/ae593b814d1cbd12a676d246ab0528a9f1b79c698c403b88255b20116c256c815b6fbbd3bfda496f5205159bdcd0fc69b75224837767981b19f43270b846e58e
+ languageName: node
+ linkType: hard
+
"@smithy/hash-stream-node@npm:^3.1.0":
version: 3.1.1
resolution: "@smithy/hash-stream-node@npm:3.1.1"
@@ -6397,6 +6921,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/invalid-dependency@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/invalid-dependency@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/96a2863eaa1405e4bd38521f159f78429e6a329a4606034630d161ba3206a1574c76f5b1099357928b9a9b2987e180f3ca51b3c4e917502c6931f7aaa666b9c9
+ languageName: node
+ linkType: hard
+
"@smithy/is-array-buffer@npm:^2.2.0":
version: 2.2.0
resolution: "@smithy/is-array-buffer@npm:2.2.0"
@@ -6424,6 +6958,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/is-array-buffer@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/is-array-buffer@npm:4.2.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/fdc097ce6a8b241565e2d56460ec289730bcd734dcde17c23d1eaaa0996337f897217166276a3fd82491fe9fd17447aadf62e8d9056b3d2b9daf192b4b668af9
+ languageName: node
+ linkType: hard
+
"@smithy/md5-js@npm:^3.0.1":
version: 3.0.2
resolution: "@smithy/md5-js@npm:3.0.2"
@@ -6468,6 +7011,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/middleware-content-length@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/middleware-content-length@npm:4.2.6"
+ dependencies:
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/224d5c5c3aa0f8d0d9a934b2c82b77ba9e0b9fdd27502adaa011f8d5c3f521e501763f377546b0945908d947b4e74fabc00d4fc282a1d3dc976339ae4fdc4945
+ languageName: node
+ linkType: hard
+
"@smithy/middleware-endpoint@npm:^3.0.2, @smithy/middleware-endpoint@npm:^3.0.3":
version: 3.0.3
resolution: "@smithy/middleware-endpoint@npm:3.0.3"
@@ -6499,6 +7053,22 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/middleware-endpoint@npm:^4.3.15, @smithy/middleware-endpoint@npm:^4.4.0":
+ version: 4.4.0
+ resolution: "@smithy/middleware-endpoint@npm:4.4.0"
+ dependencies:
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/middleware-serde": "npm:^4.2.7"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/url-parser": "npm:^4.2.6"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ tslib: "npm:^2.6.2"
+ checksum: 10/3b6c3cfa39daa27c27c10054658912eb9f4db19371461821f0fb9afeda50e97fe3af2cbe46d3c481dea63161788895217fffe1484d9cae716364385dac317bcb
+ languageName: node
+ linkType: hard
+
"@smithy/middleware-retry@npm:^3.0.4, @smithy/middleware-retry@npm:^3.0.6":
version: 3.0.6
resolution: "@smithy/middleware-retry@npm:3.0.6"
@@ -6533,6 +7103,23 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/middleware-retry@npm:^4.4.15":
+ version: 4.4.16
+ resolution: "@smithy/middleware-retry@npm:4.4.16"
+ dependencies:
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/service-error-classification": "npm:^4.2.6"
+ "@smithy/smithy-client": "npm:^4.10.1"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-retry": "npm:^4.2.6"
+ "@smithy/uuid": "npm:^1.1.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/b2d873d535eea2af1d719594a900167bf6e5b760492745ea04e9d85b1d9248944ac46c18a8595a27f4ea43d2bad92ed04d822e463ac1d12b248a07a7702a07ac
+ languageName: node
+ linkType: hard
+
"@smithy/middleware-serde@npm:^3.0.1, @smithy/middleware-serde@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/middleware-serde@npm:3.0.2"
@@ -6554,6 +7141,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/middleware-serde@npm:^4.2.7":
+ version: 4.2.7
+ resolution: "@smithy/middleware-serde@npm:4.2.7"
+ dependencies:
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/5cc20397a0ebd657825b8694599d1af5e98adb6f2232073bdd1045a3af9ecf62cfde3ac90613c94836b543b2fbff4f0be212410052089a66e54bd52638b61693
+ languageName: node
+ linkType: hard
+
"@smithy/middleware-stack@npm:^3.0.1, @smithy/middleware-stack@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/middleware-stack@npm:3.0.2"
@@ -6574,6 +7172,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/middleware-stack@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/middleware-stack@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/17f5247494caa3325375f2ff729844816ccb6f7c4acf3033dba448fe85172290f5dc4cebefabcfd9c238ba84b2eef5a4d5f689cfcff39913621feb33645ddd3b
+ languageName: node
+ linkType: hard
+
"@smithy/node-config-provider@npm:^3.1.1, @smithy/node-config-provider@npm:^3.1.2":
version: 3.1.2
resolution: "@smithy/node-config-provider@npm:3.1.2"
@@ -6598,6 +7206,18 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/node-config-provider@npm:^4.3.6":
+ version: 4.3.6
+ resolution: "@smithy/node-config-provider@npm:4.3.6"
+ dependencies:
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/shared-ini-file-loader": "npm:^4.4.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/f67ebc9f50d7e44295b6ba5ca0341b54b4fb472d85925993778ef4e3c9046146a15d01b61b31928fa23465a46974cd69b851d3e529beb6da5f4b13e4fb50fe29
+ languageName: node
+ linkType: hard
+
"@smithy/node-http-handler@npm:^3.0.1, @smithy/node-http-handler@npm:^3.1.0":
version: 3.1.0
resolution: "@smithy/node-http-handler@npm:3.1.0"
@@ -6624,6 +7244,19 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/node-http-handler@npm:^4.4.6":
+ version: 4.4.6
+ resolution: "@smithy/node-http-handler@npm:4.4.6"
+ dependencies:
+ "@smithy/abort-controller": "npm:^4.2.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/querystring-builder": "npm:^4.2.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/52afe7ee82b2e988dcd2c2ce6bcd0bc4f7aeaa7a517d722a40b9237841a586ced381b27c3d73b9dc573f5bd6168742427a155103da8474d268b5af0194fc734b
+ languageName: node
+ linkType: hard
+
"@smithy/property-provider@npm:^3.1.1, @smithy/property-provider@npm:^3.1.2":
version: 3.1.2
resolution: "@smithy/property-provider@npm:3.1.2"
@@ -6644,6 +7277,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/property-provider@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/property-provider@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/019f03694bd0c23a61e62c85271b612267886dd6b631ea8b3c79038edefdcb05136988bfb3ae86dca43868cb072545420d4dbaaea012c626107d168f8349a211
+ languageName: node
+ linkType: hard
+
"@smithy/protocol-http@npm:^4.0.1, @smithy/protocol-http@npm:^4.0.2":
version: 4.0.2
resolution: "@smithy/protocol-http@npm:4.0.2"
@@ -6664,6 +7307,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/protocol-http@npm:^5.3.6":
+ version: 5.3.6
+ resolution: "@smithy/protocol-http@npm:5.3.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/a7fe130953bcc7bfd1a51cb65443919003aa6954950489023f33355a55e096287ae10760dd2b6f4359666c3e99ad584a12422a7a31fffc4b171c35b85b16a769
+ languageName: node
+ linkType: hard
+
"@smithy/querystring-builder@npm:^3.0.1, @smithy/querystring-builder@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/querystring-builder@npm:3.0.2"
@@ -6686,6 +7339,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/querystring-builder@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/querystring-builder@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-uri-escape": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/2495004856d76c3de3b8df793a3e53233f2e993e34bb936f120aeaa74712acee03ef9b828457a8fa7ed616d01947b357f898aff37519aba80d200ed8104843bd
+ languageName: node
+ linkType: hard
+
"@smithy/querystring-parser@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/querystring-parser@npm:3.0.2"
@@ -6706,6 +7370,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/querystring-parser@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/querystring-parser@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/f3aae6561a973def4db9b3344c7860ae5659203cb912ac8aaba68e9ee67726534756fa51f93dfc23874a878e98a93744dd69946d86e045f87a08bbf42421c1f3
+ languageName: node
+ linkType: hard
+
"@smithy/service-error-classification@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/service-error-classification@npm:3.0.2"
@@ -6724,6 +7398,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/service-error-classification@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/service-error-classification@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ checksum: 10/b77e79aa66eadba65acae98cea257162cc5b4733987dea458ffc5e4c997ea8ab64bf9fb3178ac15ee2159e616c3d87a71ecc0be91f5abff624e523602a364a9f
+ languageName: node
+ linkType: hard
+
"@smithy/shared-ini-file-loader@npm:^3.1.1, @smithy/shared-ini-file-loader@npm:^3.1.2":
version: 3.1.2
resolution: "@smithy/shared-ini-file-loader@npm:3.1.2"
@@ -6744,6 +7427,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/shared-ini-file-loader@npm:^4.4.1":
+ version: 4.4.1
+ resolution: "@smithy/shared-ini-file-loader@npm:4.4.1"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/a05814e89f66fef0b79e464d06e3c36c553fb8aebc68f08674776806f7f5c11e2e5b83d85f1a450fc5d447ca4c3471a967c90ebfead28f75c829df125967ed80
+ languageName: node
+ linkType: hard
+
"@smithy/signature-v4@npm:^3.1.0":
version: 3.1.1
resolution: "@smithy/signature-v4@npm:3.1.1"
@@ -6775,6 +7468,22 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/signature-v4@npm:^5.3.6":
+ version: 5.3.6
+ resolution: "@smithy/signature-v4@npm:5.3.6"
+ dependencies:
+ "@smithy/is-array-buffer": "npm:^4.2.0"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-hex-encoding": "npm:^4.2.0"
+ "@smithy/util-middleware": "npm:^4.2.6"
+ "@smithy/util-uri-escape": "npm:^4.2.0"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/6ee715d5000b2df51cbb8bf72d4fefbe159bacf65b8c3decc93bb11b97f1eb5daf5980d6083751dc4e39a6b8954a2709bb8f12d091f8ad87d1f4910537f494f4
+ languageName: node
+ linkType: hard
+
"@smithy/smithy-client@npm:^3.1.2, @smithy/smithy-client@npm:^3.1.4":
version: 3.1.4
resolution: "@smithy/smithy-client@npm:3.1.4"
@@ -6789,6 +7498,21 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/smithy-client@npm:^4.10.0, @smithy/smithy-client@npm:^4.10.1":
+ version: 4.10.1
+ resolution: "@smithy/smithy-client@npm:4.10.1"
+ dependencies:
+ "@smithy/core": "npm:^3.19.0"
+ "@smithy/middleware-endpoint": "npm:^4.4.0"
+ "@smithy/middleware-stack": "npm:^4.2.6"
+ "@smithy/protocol-http": "npm:^5.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-stream": "npm:^4.5.7"
+ tslib: "npm:^2.6.2"
+ checksum: 10/efb5230ff1ea6d2661ca7bc89bbd5c6f4d710d7f9d62efa51f8f68476f71a5734b5877d666b2528b814b327c1deb07ee5ae877882e318d6d8fc1d9deb5e8357f
+ languageName: node
+ linkType: hard
+
"@smithy/smithy-client@npm:^4.6.4, @smithy/smithy-client@npm:^4.6.5":
version: 4.6.5
resolution: "@smithy/smithy-client@npm:4.6.5"
@@ -6813,6 +7537,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/types@npm:^4.10.0":
+ version: 4.10.0
+ resolution: "@smithy/types@npm:4.10.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/04486a46b3c3bcb8fc024951041390261edb0aa7db4b8c467dfc8a7373d09d58c0106edabbaff3163af15271f113c236e7f835a405bcd8d7652ea49578e7eb35
+ languageName: node
+ linkType: hard
+
"@smithy/types@npm:^4.5.0":
version: 4.5.0
resolution: "@smithy/types@npm:4.5.0"
@@ -6844,6 +7577,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/url-parser@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/url-parser@npm:4.2.6"
+ dependencies:
+ "@smithy/querystring-parser": "npm:^4.2.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/64080eae9fdad635178e1dbc611adcdad7a82ced4d2943445e90f0321c8a31ebcdec9fae8961d808bb59a7df100dc54acb43af158a9965e7a456acfb59bf0775
+ languageName: node
+ linkType: hard
+
"@smithy/util-base64@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-base64@npm:3.0.0"
@@ -6866,6 +7610,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-base64@npm:^4.3.0":
+ version: 4.3.0
+ resolution: "@smithy/util-base64@npm:4.3.0"
+ dependencies:
+ "@smithy/util-buffer-from": "npm:^4.2.0"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/87065ca13e3745858e0bb0ab6374433b258c378ee2a5ef865b74f6a4208c56db7db2b9ee5f888e021de0107fae49e9957662c4c6847fe10529e2f6cc882426b4
+ languageName: node
+ linkType: hard
+
"@smithy/util-body-length-browser@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-body-length-browser@npm:3.0.0"
@@ -6884,6 +7639,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-body-length-browser@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/util-body-length-browser@npm:4.2.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/deeb689b52652651c11530a324e07725805533899215ad1f93c5e9a14931443e22b313491a3c2a6d7f61d6dd1e84f9154d0d32de62bf61e0bd8e6ab7bf5f81ed
+ languageName: node
+ linkType: hard
+
"@smithy/util-body-length-node@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-body-length-node@npm:3.0.0"
@@ -6902,6 +7666,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-body-length-node@npm:^4.2.1":
+ version: 4.2.1
+ resolution: "@smithy/util-body-length-node@npm:4.2.1"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/efb1333d35120124ec0c751b7b7d5657eb9ad6d0bf6171ff61fde2504639883d36e9562613c70eca623b726193b22601c8ff60e40a8156102d4c5b12fae222f8
+ languageName: node
+ linkType: hard
+
"@smithy/util-buffer-from@npm:^2.2.0":
version: 2.2.0
resolution: "@smithy/util-buffer-from@npm:2.2.0"
@@ -6932,6 +7705,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-buffer-from@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/util-buffer-from@npm:4.2.0"
+ dependencies:
+ "@smithy/is-array-buffer": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/6a81e658554d7123fe089426a840b5e691aee4aa4f0d72b79af19dcf57ccb212dca518acb447714792d48c2dc99bda5e0e823dab05e450ee2393146706d476f9
+ languageName: node
+ linkType: hard
+
"@smithy/util-config-provider@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-config-provider@npm:3.0.0"
@@ -6950,6 +7733,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-config-provider@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/util-config-provider@npm:4.2.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/d65f36401c7a085660cf201a1b317d271e390258b619179fff88248c2db64fc35e6c62fe055f1e55be8935b06eb600379824dabf634fb26d528f54fe60c9d77b
+ languageName: node
+ linkType: hard
+
"@smithy/util-defaults-mode-browser@npm:^3.0.4":
version: 3.0.6
resolution: "@smithy/util-defaults-mode-browser@npm:3.0.6"
@@ -6976,6 +7768,18 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-defaults-mode-browser@npm:^4.3.14":
+ version: 4.3.15
+ resolution: "@smithy/util-defaults-mode-browser@npm:4.3.15"
+ dependencies:
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/smithy-client": "npm:^4.10.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/24f9b105f85a76a8df1da930892bf04e1d21dcbd1042d93b049d3c201be3e2c91c4d2b12244b740149d5b2353072578c96ef49c93e280e9948a582ab6fc2f0b3
+ languageName: node
+ linkType: hard
+
"@smithy/util-defaults-mode-node@npm:^3.0.4":
version: 3.0.6
resolution: "@smithy/util-defaults-mode-node@npm:3.0.6"
@@ -7006,6 +7810,21 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-defaults-mode-node@npm:^4.2.17":
+ version: 4.2.18
+ resolution: "@smithy/util-defaults-mode-node@npm:4.2.18"
+ dependencies:
+ "@smithy/config-resolver": "npm:^4.4.4"
+ "@smithy/credential-provider-imds": "npm:^4.2.6"
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/property-provider": "npm:^4.2.6"
+ "@smithy/smithy-client": "npm:^4.10.1"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/a558ec6ceb0bff5e8e8a237b6baa03bba550a4a0d227c1458c59819c3bdd5a4671019e66aceca3071e30558fd50d45b03f3cdf3a76ad122a14f2561aafd7c457
+ languageName: node
+ linkType: hard
+
"@smithy/util-endpoints@npm:^2.0.2":
version: 2.0.3
resolution: "@smithy/util-endpoints@npm:2.0.3"
@@ -7028,6 +7847,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-endpoints@npm:^3.2.6":
+ version: 3.2.6
+ resolution: "@smithy/util-endpoints@npm:3.2.6"
+ dependencies:
+ "@smithy/node-config-provider": "npm:^4.3.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/7ba0177c333a662d6c1019481ef262666dbcf103292c84acd2ef445bfe6a74576a83a09d1e9870692fd465ab14e2107d5f4b0c72ec55009255f26defa17229ea
+ languageName: node
+ linkType: hard
+
"@smithy/util-hex-encoding@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-hex-encoding@npm:3.0.0"
@@ -7046,6 +7876,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-hex-encoding@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/util-hex-encoding@npm:4.2.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/478773d73690e39167b67481116c4fd47cecfc97c3a935d88db9271fb0718627bec1cbc143efbf0cd49d1ac417bde7e76aa74139ea07e365b51e66797f63a45d
+ languageName: node
+ linkType: hard
+
"@smithy/util-middleware@npm:^3.0.1, @smithy/util-middleware@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/util-middleware@npm:3.0.2"
@@ -7066,6 +7905,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-middleware@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/util-middleware@npm:4.2.6"
+ dependencies:
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/ebb2f228b69301d9dbe63fb15fabe4408a9064ad834ecf4c1c77e5fca2171e11b21caa30e58553a1db0ec34b7d8cbbe7543ccc330f4b98210ef3ba5e47a1e024
+ languageName: node
+ linkType: hard
+
"@smithy/util-retry@npm:^3.0.1, @smithy/util-retry@npm:^3.0.2":
version: 3.0.2
resolution: "@smithy/util-retry@npm:3.0.2"
@@ -7088,6 +7937,17 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-retry@npm:^4.2.6":
+ version: 4.2.6
+ resolution: "@smithy/util-retry@npm:4.2.6"
+ dependencies:
+ "@smithy/service-error-classification": "npm:^4.2.6"
+ "@smithy/types": "npm:^4.10.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/039d23283975352f8d6b668cd21043071f9f9299eba76fca8ec574e0bbbb2cdf44aeada7b929fb2a9f8a19d513d6e394b4c53e3654b757916de32e28834d042c
+ languageName: node
+ linkType: hard
+
"@smithy/util-stream@npm:^3.0.2, @smithy/util-stream@npm:^3.0.4":
version: 3.0.4
resolution: "@smithy/util-stream@npm:3.0.4"
@@ -7120,6 +7980,22 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-stream@npm:^4.5.7":
+ version: 4.5.7
+ resolution: "@smithy/util-stream@npm:4.5.7"
+ dependencies:
+ "@smithy/fetch-http-handler": "npm:^5.3.7"
+ "@smithy/node-http-handler": "npm:^4.4.6"
+ "@smithy/types": "npm:^4.10.0"
+ "@smithy/util-base64": "npm:^4.3.0"
+ "@smithy/util-buffer-from": "npm:^4.2.0"
+ "@smithy/util-hex-encoding": "npm:^4.2.0"
+ "@smithy/util-utf8": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/7142ea677aaba0c89a0ff45adee50f061d40a6a3dc5d1a7666c4b61e5ee15fbad9255e1fcffdc429a30a6428d543fcbe19baefb0be50563c8ea3134de04898b2
+ languageName: node
+ linkType: hard
+
"@smithy/util-uri-escape@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-uri-escape@npm:3.0.0"
@@ -7138,6 +8014,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-uri-escape@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/util-uri-escape@npm:4.2.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/a838a3afe557d7087d4500735c79d5da72e0cd5a08f95d1a1c450ba29d9cd85c950228eedbd9b2494156f4eb8658afb0a9a5bd2df3fc4f297faed886c396242b
+ languageName: node
+ linkType: hard
+
"@smithy/util-utf8@npm:^2.0.0":
version: 2.3.0
resolution: "@smithy/util-utf8@npm:2.3.0"
@@ -7168,6 +8053,16 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/util-utf8@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "@smithy/util-utf8@npm:4.2.0"
+ dependencies:
+ "@smithy/util-buffer-from": "npm:^4.2.0"
+ tslib: "npm:^2.6.2"
+ checksum: 10/d49f58fc6681255eecc3dee39c657b80ef8a4c5617e361bdaf6aaa22f02e378622376153cafc9f0655fb80162e88fc98bbf459f8dd5ba6d7c4b9a59e6eaa05f8
+ languageName: node
+ linkType: hard
+
"@smithy/util-waiter@npm:^3.0.1":
version: 3.1.0
resolution: "@smithy/util-waiter@npm:3.1.0"
@@ -7199,6 +8094,15 @@ __metadata:
languageName: node
linkType: hard
+"@smithy/uuid@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "@smithy/uuid@npm:1.1.0"
+ dependencies:
+ tslib: "npm:^2.6.2"
+ checksum: 10/fe77b1cebbbf2d541ee2f07eec6d4573af16e08dd3228758f59dcbe85a504112cefe81b971818cf39e2e3fa0ed1fcc61d392cddc50fca13d9dc9bd835e366db0
+ languageName: node
+ linkType: hard
+
"@socket.io/component-emitter@npm:~3.1.0":
version: 3.1.2
resolution: "@socket.io/component-emitter@npm:3.1.2"
@@ -8789,6 +9693,7 @@ __metadata:
resolution: "@uppy/aws-s3@workspace:packages/@uppy/aws-s3"
dependencies:
"@aws-sdk/client-s3": "npm:^3.362.0"
+ "@aws-sdk/client-sts": "npm:^3.362.0"
"@aws-sdk/s3-request-presigner": "npm:^3.362.0"
"@uppy/companion-client": "workspace:^"
"@uppy/core": "workspace:^"