Merge branch 'main' into restore/pr-6224

This commit is contained in:
Prakash 2026-06-20 02:05:58 +05:30 committed by GitHub
commit 0a423a45b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
409 changed files with 14096 additions and 11418 deletions

View file

@ -1,6 +0,0 @@
---
"@uppy/xhr-upload": minor
"@uppy/utils": minor
---
Introduce modern, minimal TaskQueue to replace RateLimitedQueue

View file

@ -1,5 +0,0 @@
---
"@uppy/transloadit": patch
---
Add type re-export for `AssemblyInstructionsInput`

View file

@ -0,0 +1,45 @@
---
"@uppy/angular": patch
"@uppy/google-photos-picker": patch
"@uppy/google-drive-picker": patch
"@uppy/thumbnail-generator": patch
"@uppy/companion-client": patch
"@uppy/golden-retriever": patch
"@uppy/image-generator": patch
"@uppy/provider-views": patch
"@uppy/remote-sources": patch
"@uppy/screen-capture": patch
"@uppy/store-default": patch
"@uppy/google-drive": patch
"@uppy/image-editor": patch
"@uppy/transloadit": patch
"@uppy/components": patch
"@uppy/compressor": patch
"@uppy/status-bar": patch
"@uppy/xhr-upload": patch
"@uppy/companion": patch
"@uppy/dashboard": patch
"@uppy/drag-drop": patch
"@uppy/facebook": patch
"@uppy/onedrive": patch
"@uppy/unsplash": patch
"@uppy/dropbox": patch
"@uppy/locales": patch
"@uppy/aws-s3": patch
"@uppy/svelte": patch
"@uppy/webcam": patch
"@uppy/webdav": patch
"@uppy/audio": patch
"@uppy/react": patch
"@uppy/utils": patch
"@uppy/core": patch
"@uppy/form": patch
"@uppy/zoom": patch
"@uppy/box": patch
"@uppy/tus": patch
"@uppy/url": patch
"@uppy/vue": patch
"uppy": patch
---
Bump shared runtime dependencies (preact, nanoid, lodash, classnames, shallow-equal, pretty-bytes, p-queue, tus-js-client, @transloadit/types @transloadit/prettier-bytes v1, is-mobile, exifr, compressorjs, rxjs, tslib). Also includes type-only fixes in `@uppy/companion`'s `jwt.ts` and `request.ts` to track `@types/jsonwebtoken` v9 and `@types/node`.

View file

@ -0,0 +1,5 @@
---
"@uppy/dashboard": patch
---
fix "My Device" button in dashboard, it now respects the fileManagerSelectionType.

View file

@ -0,0 +1,9 @@
---
"@uppy/components": major
"@uppy/companion": major
"uppy": major
"@uppy/transloadit": minor
"@uppy/locales": minor
---
Remove @uppy/instagram references from all the packages

View file

@ -1,5 +0,0 @@
---
"@uppy/companion": patch
---
Fix bug with 429 not returning JSON response with message

View file

@ -0,0 +1,5 @@
---
"@uppy/aws-s3": patch
---
Improve type

View file

@ -0,0 +1,5 @@
---
"@uppy/companion-client": patch
---
uploadRemoteFile() now queues token request and websocket request as a single job in the request queue.

View file

@ -0,0 +1,5 @@
---
"@uppy/transloadit": minor
---
Add assemblyStatus and lastAssemblyStatus to transloadit's plugin state

View file

@ -0,0 +1,8 @@
---
"@uppy/companion-client": major
"@uppy/companion": major
---
Send token using websocket instead of window.opener.
Breaking in `@uppy/companion-client` because it needs newest version of Companion in order to work.
Breaking in `@uppy/companion` because `companion.socket()` now requires `companionOptions` to be passed as the second argument.

View file

@ -0,0 +1,5 @@
---
"@uppy/transloadit": patch
---
emit updated AssemblyState in 'transloadit:assembly-cancelled' event

View file

@ -1,5 +0,0 @@
---
"@uppy/companion": patch
---
upgrade @aws-sdk/ deps in @uppy/companion

View file

@ -1,6 +0,0 @@
---
"@uppy/transloadit": patch
"uppy": patch
---
remove monkey patch from uppy bundle package

View file

@ -0,0 +1,5 @@
---
"@uppy/companion": major
---
Upgrade to express 5 - Companion no longer works when used as a middleware with Express 4.

View file

@ -0,0 +1,5 @@
---
"@uppy/companion": patch
---
Port Companion to TypeScript. Not really a breaking change but there could be some unexpected breakage.

View file

@ -44,9 +44,6 @@ COMPANION_DROPBOX_SECRET=***
COMPANION_GOOGLE_KEY=*** COMPANION_GOOGLE_KEY=***
COMPANION_GOOGLE_SECRET=*** COMPANION_GOOGLE_SECRET=***
COMPANION_INSTAGRAM_KEY=***
COMPANION_INSTAGRAM_SECRET=***
COMPANION_FACEBOOK_KEY=*** COMPANION_FACEBOOK_KEY=***
COMPANION_FACEBOOK_SECRET=*** COMPANION_FACEBOOK_SECRET=***

View file

@ -57,7 +57,7 @@ This is not needed for changing existing components.
## Companion ## Companion
If youd like to work on features that the basic development version of Uppy If youd like to work on features that the basic development version of Uppy
doesnt support, such as Uppy integrations with Instagram/Google Drive/Facebook doesnt support, such as Uppy integrations with Google Drive/Facebook
etc., you need to set up your `.env` file (copy the contents of `.env.example` etc., you need to set up your `.env` file (copy the contents of `.env.example`
and adjust them based on what you need to work on), and run: and adjust them based on what you need to work on), and run:
@ -78,7 +78,7 @@ when files are changed.
### How the Authentication and Token mechanism works ### How the Authentication and Token mechanism works
This section describes how Authentication works between Companion and Providers. This section describes how Authentication works between Companion and Providers.
While this behaviour is the same for all Providers (Dropbox, Instagram, Google While this behaviour is the same for all Providers (Dropbox, Google
Drive, etc.), we are going to be referring to Dropbox in place of any Provider Drive, etc.), we are going to be referring to Dropbox in place of any Provider
throughout this section. throughout this section.

View file

@ -42,7 +42,7 @@ jobs:
node-version: lts/* node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: run:
corepack yarn install corepack yarn install --immutable
env: env:
# https://docs.cypress.io/guides/references/advanced-installation#Skipping-installation # https://docs.cypress.io/guides/references/advanced-installation#Skipping-installation
CYPRESS_INSTALL_BINARY: 0 CYPRESS_INSTALL_BINARY: 0

View file

@ -47,7 +47,7 @@ jobs:
node-version: ${{matrix.node-version}} node-version: ${{matrix.node-version}}
- name: Install dependencies - name: Install dependencies
run: run:
corepack yarn install corepack yarn install --immutable
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: corepack yarn workspace @uppy/dashboard playwright install --with-deps run: corepack yarn workspace @uppy/dashboard playwright install --with-deps
- name: Build - name: Build
@ -84,7 +84,7 @@ jobs:
node-version: lts/* node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: run:
corepack yarn install corepack yarn install --immutable
- run: corepack yarn run typecheck - run: corepack yarn run typecheck
lint_js: lint_js:

View file

@ -38,56 +38,49 @@ jobs:
path: /tmp/companion-${{ github.sha }}.tar.gz path: /tmp/companion-${{ github.sha }}.tar.gz
docker: docker:
name: DockerHub name: DockerHub (${{ matrix.platform }})
runs-on: ubuntu-latest strategy:
env: matrix:
DOCKER_BUILDKIT: 0 include:
COMPOSE_DOCKER_CLI_BUILD: 0 - runner: ubuntu-24.04
platform: linux/amd64
platform_tag: amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
platform_tag: arm64
runs-on: ${{ matrix.runner }}
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Docker meta - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
id: docker_meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: transloadit/companion
tags: |
type=edge
type=raw,value=latest,enable=false
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub - name: Log in to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
username: ${{secrets.DOCKER_USERNAME}} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{secrets.DOCKER_PASSWORD}} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
push: true push: true
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
file: Dockerfile file: Dockerfile
tags: ${{ steps.docker_meta.outputs.tags }} tags: transloadit/companion:edge-${{ matrix.platform_tag }}
labels: ${{ steps.docker_meta.outputs.labels }}
heroku: docker-manifest:
name: Heroku name: DockerHub manifest
needs: docker
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
uses: actions/checkout@v6 - name: Log in to DockerHub
- name: Alter dockerfile uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
run: |
sed -i 's/^EXPOSE 3020$/EXPOSE $PORT/g' Dockerfile
# https://github.com/AkhileshNS/heroku-deploy/issues/188
- name: Install Heroku CLI
run: |
curl https://cli-assets.heroku.com/install.sh | sh
- name: Deploy to heroku
uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 # v3.14.15
with: with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}} username: ${{ secrets.DOCKER_USERNAME }}
heroku_app_name: companion-demo password: ${{ secrets.DOCKER_PASSWORD }}
heroku_email: ${{secrets.HEROKU_EMAIL}} - name: Publish edge manifest
usedocker: true run: |
docker buildx imagetools create \
--tag transloadit/companion:edge \
transloadit/companion:edge-amd64 \
transloadit/companion:edge-arm64

View file

@ -30,18 +30,6 @@ jobs:
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run:
echo "dir=$(corepack yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- uses: actions/cache@v5
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:

View file

@ -50,7 +50,7 @@ jobs:
- name: Create Release Pull Request or Publish - name: Create Release Pull Request or Publish
id: changesets id: changesets
uses: changesets/action@v1 uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d
with: with:
version: corepack yarn run version version: corepack yarn run version
publish: corepack yarn run release publish: corepack yarn run release
@ -102,7 +102,7 @@ jobs:
# set latest tag for default branch # set latest tag for default branch
type=raw,value=latest,enable=true type=raw,value=latest,enable=true
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to DockerHub - name: Log in to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:

View file

@ -9,5 +9,6 @@ initScope: uppy
nodeLinker: node-modules nodeLinker: node-modules
npmPublishAccess: public npmMinimalAgeGate: 2880
npmPublishAccess: public

View file

@ -2,7 +2,7 @@
Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can
use this from a CDN use this from a CDN
(`<script src="https://releases.transloadit.com/uppy/v5.2.3/uppy.min.js"></script>`) (`<script src="https://releases.transloadit.com/uppy/v5.2.4/uppy.min.js"></script>`)
or bundle it with your webapp. or bundle it with your webapp.
Note that the recommended way to use Uppy is to install it with yarn/npm and use Note that the recommended way to use Uppy is to install it with yarn/npm and use
@ -83,7 +83,7 @@ inline into the page. This, and many more configuration options can be found
here: <https://uppy.io/docs/dashboard/>. here: <https://uppy.io/docs/dashboard/>.
Uppy has many more Plugins besides Xhr and the Dashboard. For example, you can Uppy has many more Plugins besides Xhr and the Dashboard. For example, you can
enable Webcam, Instagram, or video encoding support. Note that for some Plugins, enable Webcam or video encoding support. Note that for some Plugins,
you will need to run a server side component called: Companion. Those plugins you will need to run a server side component called: Companion. Those plugins
are marked with a (c) symbol. Alternatively, you can sign up for a free are marked with a (c) symbol. Alternatively, you can sign up for a free
Transloadit account. Transloadit runs Companion for you, tusd servers to handle Transloadit account. Transloadit runs Companion for you, tusd servers to handle

View file

@ -46,7 +46,7 @@ yarn workspace @uppy/[package-name] build
- **Uppy Core** (`@uppy/core`) - Main class that manages plugins, state, and events - **Uppy Core** (`@uppy/core`) - Main class that manages plugins, state, and events
- **Plugins** - Modular components for different functionalities: - **Plugins** - Modular components for different functionalities:
- **UI Plugins**: Dashboard, Drag & Drop, File Input, Webcam, etc. - **UI Plugins**: Dashboard, Drag & Drop, File Input, Webcam, etc.
- **Provider Plugins**: Google Drive, Dropbox, Instagram, etc. (require Companion) - **Provider Plugins**: Google Drive, Dropbox, etc. (require Companion)
- **Uploader Plugins**: Tus (resumable), XHR Upload, AWS S3, etc. - **Uploader Plugins**: Tus (resumable), XHR Upload, AWS S3, etc.
- **Utility Plugins**: Golden Retriever (recovery), Thumbnail Generator, etc. - **Utility Plugins**: Golden Retriever (recovery), Thumbnail Generator, etc.

View file

@ -24,13 +24,12 @@ FROM node:22.22.1-alpine
WORKDIR /app WORKDIR /app
# copy required files from build stage. # copy required files from build stage.
COPY --from=build /app/packages/@uppy/companion/bin /app/bin COPY --from=build /app/packages/@uppy/companion/dist /app/dist
COPY --from=build /app/packages/@uppy/companion/lib /app/lib
COPY --from=build /app/packages/@uppy/companion/package.json /app/package.json COPY --from=build /app/packages/@uppy/companion/package.json /app/package.json
COPY --from=build /app/packages/@uppy/companion/node_modules /app/node_modules COPY --from=build /app/packages/@uppy/companion/node_modules /app/node_modules
ENV PATH "${PATH}:/app/node_modules/.bin" ENV PATH "${PATH}:/app/node_modules/.bin"
CMD ["node","/app/bin/companion"] CMD ["node","/app/dist/bin/companion.js"]
# This can be overruled later # This can be overruled later
EXPOSE 3020 EXPOSE 3020

View file

@ -7,7 +7,7 @@ with any application. Its fast, has a comprehensible API and lets you worry
about more important problems than building a file uploader. about more important problems than building a file uploader.
- **Fetch** files from local disk, remote URLs, Google Drive, Dropbox, Box, - **Fetch** files from local disk, remote URLs, Google Drive, Dropbox, Box,
Instagram or snap and record selfies with a camera or snap and record selfies with a camera
- **Preview** and edit metadata with a nice interface - **Preview** and edit metadata with a nice interface
- **Upload** to the final destination, optionally process/encode - **Upload** to the final destination, optionally process/encode
@ -82,7 +82,7 @@ For the supported frameworks (except Angular) Uppy offers three ways to build us
- Lightweight, modular plugin-based architecture, light on dependencies :zap: - Lightweight, modular plugin-based architecture, light on dependencies :zap:
- Resumable file uploads via the open [tus](https://tus.io/) standard, so large - Resumable file uploads via the open [tus](https://tus.io/) standard, so large
uploads survive network hiccups uploads survive network hiccups
- Supports picking files from: Webcam, Dropbox, Box, Google Drive, Instagram, - Supports picking files from: Webcam, Dropbox, Box, Google Drive,
bypassing the users device where possible, syncing between servers directly bypassing the users device where possible, syncing between servers directly
via [@uppy/companion](https://uppy.io/docs/companion) via [@uppy/companion](https://uppy.io/docs/companion)
- Works great with file encoding and processing backends, such as - Works great with file encoding and processing backends, such as
@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus
``` ```
Add CSS Add CSS
[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.3/uppy.min.css), [uppy.min.css](https://releases.transloadit.com/uppy/v5.2.4/uppy.min.css),
either to your HTML pages `<head>` or include in JS, if your bundler of choice either to your HTML pages `<head>` or include in JS, if your bundler of choice
supports it. supports it.
@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
```html ```html
<!-- 1. Add CSS to `<head>` --> <!-- 1. Add CSS to `<head>` -->
<link <link
href="https://releases.transloadit.com/uppy/v5.2.3/uppy.min.css" href="https://releases.transloadit.com/uppy/v5.2.4/uppy.min.css"
rel="stylesheet" rel="stylesheet"
/> />
@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
Uppy, Uppy,
Dashboard, Dashboard,
Tus, Tus,
} from 'https://releases.transloadit.com/uppy/v5.2.3/uppy.min.mjs' } from 'https://releases.transloadit.com/uppy/v5.2.4/uppy.min.mjs'
const uppy = new Uppy() const uppy = new Uppy()
uppy.use(Dashboard, { target: '#files-drag-drop' }) uppy.use(Dashboard, { target: '#files-drag-drop' })
@ -140,7 +140,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
- [Uppy](https://uppy.io/docs/uppy/) — full list of options, methods and events - [Uppy](https://uppy.io/docs/uppy/) — full list of options, methods and events
- [Companion](https://uppy.io/docs/companion/) — setting up and running a - [Companion](https://uppy.io/docs/companion/) — setting up and running a
Companion instance, which adds support for Instagram, Dropbox, Box, Google Companion instance, which adds support for Dropbox, Box, Google
Drive and remote URLs Drive and remote URLs
- [React](https://uppy.io/docs/react/) — components to integrate Uppy UI plugins - [React](https://uppy.io/docs/react/) — components to integrate Uppy UI plugins
with React apps with React apps
@ -153,7 +153,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
- [`Dashboard`](https://uppy.io/docs/dashboard/) — universal UI with previews, - [`Dashboard`](https://uppy.io/docs/dashboard/) — universal UI with previews,
progress bars, metadata editor and all the cool stuff. Required for most UI progress bars, metadata editor and all the cool stuff. Required for most UI
plugins like Webcam and Instagram plugins like Webcam
- Headless components ([react](https://uppy.io/docs/react/), [svelte](https://uppy.io/docs/svelte/), [vue](https://uppy.io/docs/vue/)) - Headless components ([react](https://uppy.io/docs/react/), [svelte](https://uppy.io/docs/svelte/), [vue](https://uppy.io/docs/vue/))
### Sources ### Sources
@ -166,8 +166,6 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
Google Drive Google Drive
- ⓒ [`Dropbox`](https://uppy.io/docs/dropbox/) — import files from Dropbox - ⓒ [`Dropbox`](https://uppy.io/docs/dropbox/) — import files from Dropbox
- ⓒ [`Box`](https://uppy.io/docs/box/) — import files from Box - ⓒ [`Box`](https://uppy.io/docs/box/) — import files from Box
- ⓒ [`Instagram`](https://uppy.io/docs/instagram/) — import images and videos
from Instagram
- ⓒ [`Facebook`](https://uppy.io/docs/facebook/) — import images and videos from - ⓒ [`Facebook`](https://uppy.io/docs/facebook/) — import images and videos from
Facebook Facebook
- ⓒ [`OneDrive`](https://uppy.io/docs/onedrive/) — import files from Microsoft - ⓒ [`OneDrive`](https://uppy.io/docs/onedrive/) — import files from Microsoft
@ -222,7 +220,7 @@ cases, the file input leaves some to be desired:
- Uppy supports editing meta information before uploading. - Uppy supports editing meta information before uploading.
- Uppy allows cropping images before uploading. - Uppy allows cropping images before uploading.
- Theres the situation where people are using their mobile devices and want to - Theres the situation where people are using their mobile devices and want to
upload on the go, but they have their picture on Instagram, files in Dropbox upload on the go, but they have files in Dropbox
or a plain file URL from anywhere on the open web. Uppy allows to pick files or a plain file URL from anywhere on the open web. Uppy allows to pick files
from those and push it to the destination without downloading it to your from those and push it to the destination without downloading it to your
mobile device first. mobile device first.
@ -272,7 +270,7 @@ If you want resumability with the Tus plugin, use
[one of the tus server implementations](https://tus.io/implementations.html) 👌🏼 [one of the tus server implementations](https://tus.io/implementations.html) 👌🏼
And youll need [`@uppy/companion`](https://uppy.io/docs/companion) if youd And youll need [`@uppy/companion`](https://uppy.io/docs/companion) if youd
like your users to be able to pick files from Instagram, Google Drive, Dropbox like your users to be able to pick files from Google Drive, Dropbox
or via direct URLs (with more services coming). or via direct URLs (with more services coming).
## Contributions are welcome ## Contributions are welcome

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@ -14,10 +14,11 @@
"bin/**", "bin/**",
"e2e/**", "e2e/**",
"private/**", "private/**",
"!packages/**/{dist,lib}/**", "!packages/**/{dist,lib}",
"!node_modules", "!node_modules",
"!.svelte-kit", "!.svelte-kit",
"!packages/@uppy/components/src/input.css" "!packages/@uppy/components/src/input.css",
"!examples/angular/**/*.html"
] ]
}, },
"formatter": { "formatter": {
@ -25,20 +26,11 @@
"indentStyle": "space", "indentStyle": "space",
"formatWithErrors": true "formatWithErrors": true
}, },
"html": {
"experimentalFullSupportEnabled": true
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"includes": [
"packages/**",
"examples/**",
"scripts/**",
"private/**",
"!packages/**/{dist,lib}/**",
"!node_modules",
"!.svelte-kit",
"!packages/@uppy/components/src/input.css",
"!**/*.vue",
"!**/*.svelte"
],
"rules": { "rules": {
"recommended": true, "recommended": true,
"suspicious": { "suspicious": {
@ -48,22 +40,23 @@
"useExhaustiveDependencies": "error", "useExhaustiveDependencies": "error",
"useHookAtTopLevel": "error", "useHookAtTopLevel": "error",
"noUnusedFunctionParameters": "off", "noUnusedFunctionParameters": "off",
"noUnusedImports": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": { "noUnusedVariables": {
"level": "error", "level": "error",
"options": { "options": {
"ignoreRestSiblings": true "ignoreRestSiblings": true
} }
} },
"noNestedComponentDefinitions": "error",
"noReactPropAssignments": "error"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNonNullAssertion": "off"
}, },
"a11y": { "a11y": {
"noSvgWithoutTitle": "off" "noSvgWithoutTitle": "off",
}, "useSemanticElements": "off"
"nursery": {
"noNestedComponentDefinitions": "error",
"noReactPropAssign": "error"
} }
} }
}, },
@ -80,5 +73,36 @@
"organizeImports": "on" "organizeImports": "on"
} }
} }
} },
"overrides": [
{
"includes": ["packages/@uppy/companion/**"],
"linter": {
"rules": {
"complexity": {
"useLiteralKeys": "off"
}
}
}
},
{
"includes": ["**/*.vue", "**/*.svelte", "packages/@uppy/vue/**/*.ts"],
"linter": {
"rules": {
"correctness": {
"useHookAtTopLevel": "off",
"useExhaustiveDependencies": "off"
}
}
}
},
{
"includes": ["examples/nextjs/**/*.css"],
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
]
} }

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -11,11 +11,11 @@
}, },
"devDependencies": { "devDependencies": {
"@uppy/companion": "workspace:*", "@uppy/companion": "workspace:*",
"body-parser": "^1.20.4", "body-parser": "^2.2.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.1", "dotenv": "^17.4.2",
"express": "^4.22.0", "express": "^5.2.1",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"vite": "^7.1.11" "vite": "^7.1.11"

View file

@ -66,4 +66,4 @@ const server = app.listen(3020, () => {
console.log('listening on port 3020') console.log('listening on port 3020')
}) })
companion.socket(server) companion.socket(server, options)

View file

@ -15,6 +15,6 @@
"@aws-sdk/s3-request-presigner": "^3.338.0", "@aws-sdk/s3-request-presigner": "^3.338.0",
"body-parser": "^1.20.4", "body-parser": "^1.20.4",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.22.0" "express": "^5.2.1"
} }
} }

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Uppy AWS upload example</title> <title>Uppy AWS upload example</title>
@ -24,7 +24,8 @@
</footer> </footer>
<noscript>You need JavaScript to run this example.</noscript> <noscript>You need JavaScript to run this example.</noscript>
<script type="module"> <script type="module">
import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs' import { AwsS3, Dashboard, Uppy } from './uppy.min.mjs'
function onUploadComplete(result) { function onUploadComplete(result) {
console.log( console.log(
'Upload complete! Weve uploaded these files:', 'Upload complete! Weve uploaded these files:',
@ -34,7 +35,7 @@
function onUploadSuccess(file, data) { function onUploadSuccess(file, data) {
console.log( console.log(
'Upload success! Weve uploaded this file:', 'Upload success! Weve uploaded this file:',
file.meta['name'], file.meta.name,
) )
} }
{ {

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Uppy AWS upload example</title> <title>Uppy AWS upload example</title>
@ -16,7 +16,8 @@
</footer> </footer>
<noscript>You need JavaScript to run this example.</noscript> <noscript>You need JavaScript to run this example.</noscript>
<script type="module"> <script type="module">
import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs' import { AwsS3, Dashboard, Uppy } from './uppy.min.mjs'
/** /**
* This generator transforms a deep object into URL-encodable pairs * This generator transforms a deep object into URL-encodable pairs
* to work with `URLSearchParams` on the client and `body-parser` on the server. * to work with `URLSearchParams` on the client and `body-parser` on the server.
@ -258,7 +259,7 @@
uppy.on('upload-success', (file, data) => { uppy.on('upload-success', (file, data) => {
console.log( console.log(
'Upload success! Weve uploaded this file:', 'Upload success! Weve uploaded this file:',
file.meta['name'], file.meta.name,
) )
}) })
} }

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -11,14 +11,14 @@
</head> </head>
<body> <body>
<noscript>You need JavaScript enabled for this example to work.</noscript> <noscript>You need JavaScript enabled for this example to work.</noscript>
<button id="uppyModalOpener">Open Modal</button> <button type="button" id="uppyModalOpener">Open Modal</button>
<script type="module"> <script type="module">
import { import {
Uppy,
Dashboard, Dashboard,
Webcam,
Tus, Tus,
Uppy,
Webcam,
} from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs' } from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs'
const uppy = new Uppy({ debug: true, autoProceed: false }) const uppy = new Uppy({ debug: true, autoProceed: false })

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -10,16 +10,16 @@
"@uppy/google-drive": "workspace:*", "@uppy/google-drive": "workspace:*",
"@uppy/provider-views": "workspace:*", "@uppy/provider-views": "workspace:*",
"@uppy/tus": "workspace:*", "@uppy/tus": "workspace:*",
"preact": "^10.26.10" "preact": "^10.29.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@uppy/companion": "workspace:*", "@uppy/companion": "workspace:*",
"body-parser": "^1.20.4", "body-parser": "^2.2.2",
"dotenv": "^16.0.1", "dotenv": "^17.4.2",
"express": "^4.22.0", "express": "^5.2.1",
"express-session": "^1.15.6", "express-session": "^1.15.6",
"npm-run-all": "^4.1.2", "npm-run-all": "^4.1.2",
"vite": "^7.1.11" "vite": "^7.1.11"

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -10,8 +10,8 @@
"cors": "^2.8.5" "cors": "^2.8.5"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^16.0.1", "dotenv": "^17.4.2",
"express": "^4.22.0", "express": "^5.2.1",
"vite": "^7.1.11" "vite": "^7.1.11"
}, },
"private": true, "private": true,

View file

@ -10,23 +10,18 @@
/> />
</head> </head>
<body> <body>
<button id="uppyModalOpener">Open Modal</button> <button type="button" id="uppyModalOpener">Open Modal</button>
<noscript>This web page requires JavaScript to work properly.</noscript> <noscript>This web page requires JavaScript to work properly.</noscript>
<script type="module"> <script type="module">
import { import {
Uppy,
Dashboard, Dashboard,
Instagram,
GoogleDrive, GoogleDrive,
Tus, Tus,
Uppy,
} from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs' } from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs'
const uppy = new Uppy({ debug: true, autoProceed: false }) const uppy = new Uppy({ debug: true, autoProceed: false })
.use(Dashboard, { trigger: '#uppyModalOpener' }) .use(Dashboard, { trigger: '#uppyModalOpener' })
.use(Instagram, {
target: Dashboard,
companionUrl: 'http://localhost:3020',
})
.use(GoogleDrive, { .use(GoogleDrive, {
target: Dashboard, target: Dashboard,
companionUrl: 'http://localhost:3020', companionUrl: 'http://localhost:3020',

View file

@ -4,7 +4,7 @@
"dependencies": { "dependencies": {
"@uppy/companion": "workspace:*", "@uppy/companion": "workspace:*",
"body-parser": "^1.20.4", "body-parser": "^1.20.4",
"express": "^4.22.0", "express": "^5.2.1",
"express-session": "^1.15.6", "express-session": "^1.15.6",
"light-server": "^2.4.0" "light-server": "^2.4.0"
}, },

View file

@ -32,10 +32,6 @@ const companionOptions = {
key: 'your google key', key: 'your google key',
secret: 'your google secret', secret: 'your google secret',
}, },
instagram: {
key: 'your instagram key',
secret: 'your instagram secret',
},
dropbox: { dropbox: {
key: 'your dropbox key', key: 'your dropbox key',
secret: 'your dropbox secret', secret: 'your dropbox secret',
@ -75,7 +71,7 @@ app.use((err, req, res) => {
res.status(500).json({ message: err.message, error: err }) res.status(500).json({ message: err.message, error: err })
}) })
companion.socket(app.listen(3020)) companion.socket(app.listen(3020), companionOptions)
console.log('Welcome to Companion!') console.log('Welcome to Companion!')
console.log(`Listening on http://0.0.0.0:${3020}`) console.log(`Listening on http://0.0.0.0:${3020}`)

View file

@ -9,7 +9,7 @@
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.3.0",
"@uppy/core": "workspace:*", "@uppy/core": "workspace:*",
"@uppy/react": "workspace:*", "@uppy/react": "workspace:*",
"@uppy/remote-sources": "workspace:*", "@uppy/remote-sources": "workspace:*",
@ -23,13 +23,14 @@
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^6.0.2",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^4.1.6",
"@vitest/browser-playwright": "^4.1.6",
"msw": "^2.10.4", "msw": "^2.10.4",
"playwright": "1.57.0", "playwright": "1.60.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^7.1.11", "vite": "^8.0.14",
"vitest": "^3.2.4", "vitest": "^4.1.6",
"vitest-browser-react": "^1.0.0" "vitest-browser-react": "^2.2.0"
} }
} }

View file

@ -1,4 +1,4 @@
/** biome-ignore-all lint/nursery/useUniqueElementIds: it's fine */ /** biome-ignore-all lint/correctness/useUniqueElementIds: it's fine */
import Uppy, { type UppyFile } from '@uppy/core' import Uppy, { type UppyFile } from '@uppy/core'
import UppyImageEditor from '@uppy/image-editor' import UppyImageEditor from '@uppy/image-editor'
import { import {

View file

@ -1,6 +1,6 @@
import { userEvent } from '@vitest/browser/context'
import { setupWorker } from 'msw/browser' import { setupWorker } from 'msw/browser'
import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-react' import { render } from 'vitest-browser-react'
import { tusHandlers } from '../../shared/tusHandlers.js' import { tusHandlers } from '../../shared/tusHandlers.js'
import App from '../src/App' import App from '../src/App'
@ -19,7 +19,7 @@ const createMockFile = (name: string, type: string, size: number = 1024) => {
describe('App', () => { describe('App', () => {
test('renders all main sections and upload button is initially disabled', async () => { test('renders all main sections and upload button is initially disabled', async () => {
const screen = render(<App />) const screen = await render(<App />)
await expect.element(screen.getByText('With list')).toBeInTheDocument() await expect.element(screen.getByText('With list')).toBeInTheDocument()
await expect.element(screen.getByText('With grid')).toBeInTheDocument() await expect.element(screen.getByText('With grid')).toBeInTheDocument()
@ -33,7 +33,7 @@ describe('App', () => {
}) })
test('can add and remove files and upload', async () => { test('can add and remove files and upload', async () => {
const screen = render(<App />) const screen = await render(<App />)
const fileInput = document.getElementById( const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy', 'uppy-dropzone-file-input-uppy',
@ -59,7 +59,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => { describe('ScreenCapture Component', () => {
test('renders with title, control buttons, and close functionality works', async () => { test('renders with title, control buttons, and close functionality works', async () => {
const screen = render(<App />) const screen = await render(<App />)
await screen.getByRole('button', { name: 'Screen Capture' }).click() await screen.getByRole('button', { name: 'Screen Capture' }).click()
@ -90,7 +90,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => { describe('Webcam Component', () => {
test('renders with title, control buttons, and close functionality works', async () => { test('renders with title, control buttons, and close functionality works', async () => {
const screen = render(<App />) const screen = await render(<App />)
await screen.getByRole('button', { name: 'Webcam' }).click() await screen.getByRole('button', { name: 'Webcam' }).click()
@ -121,7 +121,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => { describe('RemoteSource Component', () => {
test('renders login button and login interaction works', async () => { test('renders login button and login interaction works', async () => {
const screen = render(<App />) const screen = await render(<App />)
await screen.getByRole('button', { name: 'Dropbox' }).click() await screen.getByRole('button', { name: 'Dropbox' }).click()
@ -129,6 +129,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument() await expect.element(loginButton).toBeInTheDocument()
await loginButton.click() await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
}) })
}) })

View file

@ -6,4 +6,11 @@ import { defineConfig } from 'vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
// @vitejs/plugin-react v5+ no longer adds react/react-dom to resolve.dedupe
// automatically (it did in v4). Without deduping, the linked @uppy/react
// workspace pulls in a second React copy, causing "Invalid hook call".
// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md (v5.0.0)
resolve: {
dedupe: ['react', 'react-dom'],
},
}) })

View file

@ -1,13 +1,21 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
// @vitejs/plugin-react v5+ no longer adds react/react-dom to resolve.dedupe
// automatically (it did in v4). Without deduping, the linked @uppy/react
// workspace pulls in a second React copy, causing "Invalid hook call".
// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md (v5.0.0)
resolve: {
dedupe: ['react', 'react-dom'],
},
test: { test: {
browser: { browser: {
enabled: true, enabled: true,
provider: 'playwright', provider: playwright(),
instances: [{ browser: 'chromium' }], instances: [{ browser: 'chromium' }],
}, },
}, },

View file

@ -20,14 +20,14 @@
"@uppy/transloadit": "workspace:*", "@uppy/transloadit": "workspace:*",
"@uppy/tus": "workspace:*", "@uppy/tus": "workspace:*",
"@uppy/xhr-upload": "workspace:*", "@uppy/xhr-upload": "workspace:*",
"express": "^4.22.0", "express": "^5.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router": "^7.12.0" "react-router": "^7.12.0"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.12.0", "@react-router/dev": "^7.12.0",
"@types/express": "^4.17.20", "@types/express": "^5.0.6",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"tsx": "^4.0.0", "tsx": "^4.0.0",

View file

@ -41,7 +41,9 @@ async function startServer() {
// TUS upload endpoints (before React Router) // TUS upload endpoints (before React Router)
app.all('/api/upload/tus', (req, res) => tusServer.handle(req, res)) app.all('/api/upload/tus', (req, res) => tusServer.handle(req, res))
app.all('/api/upload/tus/*', (req, res) => tusServer.handle(req, res)) app.all('/api/upload/tus/{*splat}', (req, res) =>
tusServer.handle(req, res),
)
// Handle Chrome DevTools requests silently // Handle Chrome DevTools requests silently
app.get('/.well-known/appspecific/com.chrome.devtools.json', (req, res) => { app.get('/.well-known/appspecific/com.chrome.devtools.json', (req, res) => {
@ -49,7 +51,7 @@ async function startServer() {
}) })
// React Router handles all other routes // React Router handles all other routes
app.all('*', reactRouterHandler) app.all('/{*splat}', reactRouterHandler)
const port = process.env.PORT || 3000 const port = process.env.PORT || 3000
const server = app.listen(port, () => { const server = app.listen(port, () => {

View file

@ -26,15 +26,16 @@
"@sveltejs/kit": "^2.49.5", "@sveltejs/kit": "^2.49.5",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^4.1.6",
"@vitest/browser-playwright": "^4.1.6",
"msw": "^2.10.4", "msw": "^2.10.4",
"playwright": "1.57.0", "playwright": "1.60.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.2.5", "vite": "^6.2.5",
"vitest": "^3.2.4", "vitest": "^4.1.6",
"vitest-browser-svelte": "^1.0.0" "vitest-browser-svelte": "^2.1.1"
} }
} }

View file

@ -20,7 +20,7 @@ const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
> >
<div class="flex items-center justify-center gap-4"> <div class="flex items-center justify-center gap-4">
<input {...getFileInputProps()} class="hidden" /> <input {...getFileInputProps()} class="hidden" />
<button <button type="button"
{...getButtonProps()} {...getButtonProps()}
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >
@ -30,7 +30,7 @@ const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
Device Device
</button> </button>
<button <button type="button"
onclick={() => openModal('webcam')} onclick={() => openModal('webcam')}
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >
@ -40,7 +40,7 @@ const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
Webcam Webcam
</button> </button>
<button <button type="button"
onclick={() => openModal('screen-capture')} onclick={() => openModal('screen-capture')}
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >
@ -50,7 +50,7 @@ const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
Screen Capture Screen Capture
</button> </button>
<button <button type="button"
onclick={() => openModal('dropbox')} onclick={() => openModal('dropbox')}
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >

View file

@ -24,6 +24,7 @@ const editor = useImageEditor({ file })
</div> </div>
<div class="mb-4"> <div class="mb-4">
<!-- biome-ignore lint/a11y/useAltText: alt is provided via editor.getImageProps() spread -->
<img <img
class="w-full max-h-[400px] rounded-lg border-2" class="w-full max-h-[400px] rounded-lg border-2"
{...editor.getImageProps()} {...editor.getImageProps()}
@ -41,31 +42,31 @@ const editor = useImageEditor({ file })
</div> </div>
<div class="flex gap-2 flex-wrap mb-4"> <div class="flex gap-2 flex-wrap mb-4">
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getRotateButtonProps(-90)} {...editor.getRotateButtonProps(-90)}
> >
↶ -90° ↶ -90°
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getRotateButtonProps(90)} {...editor.getRotateButtonProps(90)}
> >
↷ +90° ↷ +90°
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getFlipHorizontalButtonProps()} {...editor.getFlipHorizontalButtonProps()}
> >
⇆ Flip ⇆ Flip
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getZoomButtonProps(0.1)} {...editor.getZoomButtonProps(0.1)}
> >
+ Zoom + Zoom
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getZoomButtonProps(-0.1)} {...editor.getZoomButtonProps(-0.1)}
> >
@ -74,25 +75,25 @@ const editor = useImageEditor({ file })
</div> </div>
<div class="flex gap-2 flex-wrap mb-4"> <div class="flex gap-2 flex-wrap mb-4">
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropSquareButtonProps()} {...editor.getCropSquareButtonProps()}
> >
1:1 1:1
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropLandscapeButtonProps()} {...editor.getCropLandscapeButtonProps()}
> >
16:9 16:9
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropPortraitButtonProps()} {...editor.getCropPortraitButtonProps()}
> >
9:16 9:16
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getResetButtonProps()} {...editor.getResetButtonProps()}
> >
@ -101,13 +102,13 @@ const editor = useImageEditor({ file })
</div> </div>
<div class="flex gap-4 justify-end"> <div class="flex gap-4 justify-end">
<button <button type="button"
class="bg-gray-500 text-white px-4 py-2 rounded-md" class="bg-gray-500 text-white px-4 py-2 rounded-md"
{...editor.getCancelButtonProps({ onClick: close })} {...editor.getCancelButtonProps({ onClick: close })}
> >
Cancel Cancel
</button> </button>
<button <button type="button"
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300" class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...editor.getSaveButtonProps({ onClick: close })} {...editor.getSaveButtonProps({ onClick: close })}
> >

View file

@ -42,36 +42,37 @@ const {
<p>{mediaError.message ? `Camera error: ${mediaError.message}` : 'An unknown camera error occurred.'}</p> <p>{mediaError.message ? `Camera error: ${mediaError.message}` : 'An unknown camera error occurred.'}</p>
</div> </div>
{/if} {/if}
<!-- biome-ignore lint/a11y/useMediaCaption: live camera preview does not require captions -->
<video <video
class="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]" class="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
{...videoProps} {...videoProps}
></video> ></video>
<div class="flex gap-4 flex-wrap mt-4"> <div class="flex gap-4 flex-wrap mt-4">
<button <button type="button"
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300" class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...primaryActionButtonProps} {...primaryActionButtonProps}
> >
{primaryActionButtonLabel} {primaryActionButtonLabel}
</button> </button>
<button <button type="button"
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300" class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...recordButtonProps} {...recordButtonProps}
> >
Record Record
</button> </button>
<button <button type="button"
class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300" class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
{...stopRecordingButtonProps} {...stopRecordingButtonProps}
> >
Stop Stop
</button> </button>
<button <button type="button"
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300" class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...submitButtonProps} {...submitButtonProps}
> >
Submit Submit
</button> </button>
<button <button type="button"
class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300" class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
{...discardButtonProps} {...discardButtonProps}
> >

View file

@ -18,6 +18,7 @@ const dtf = new Intl.DateTimeFormat('en-US', {
timeStyle: 'short', timeStyle: 'short',
}) })
// biome-ignore lint/correctness/noUnusedVariables: used as a Svelte action via use:setFolderCheckboxIndeterminate
function setFolderCheckboxIndeterminate( function setFolderCheckboxIndeterminate(
node: HTMLInputElement, node: HTMLInputElement,
item: PartialTreeFolderNode, item: PartialTreeFolderNode,

View file

@ -14,9 +14,9 @@ let hideUploadButton = false
</script> </script>
<main class="p-5 max-w-xl mx-auto"> <main class="p-5 max-w-xl mx-auto">
<button onclick={() => (disabled = !disabled)}>Toggle dashboard</button> <button type="button" onclick={() => (disabled = !disabled)}>Toggle dashboard</button>
<Dashboard props={{disabled: disabled}} uppy={uppy} /> <Dashboard props={{disabled: disabled}} uppy={uppy} />
<button onclick={() => (hideUploadButton = !hideUploadButton)}>Toggle statusbar</button> <button type="button" onclick={() => (hideUploadButton = !hideUploadButton)}>Toggle statusbar</button>
<div id="statusbar-container"> <div id="statusbar-container">
<StatusBar props={{ hideUploadButton }} uppy={uppy} /> <StatusBar props={{ hideUploadButton }} uppy={uppy} />
</div> </div>

View file

@ -1,6 +1,6 @@
import { userEvent } from '@vitest/browser/context'
import { setupWorker } from 'msw/browser' import { setupWorker } from 'msw/browser'
import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-svelte' import { render } from 'vitest-browser-svelte'
import { tusHandlers } from '../../shared/tusHandlers.js' import { tusHandlers } from '../../shared/tusHandlers.js'
import PropsReactivity from '../src/components/test/props-reactivity.svelte' import PropsReactivity from '../src/components/test/props-reactivity.svelte'
@ -20,7 +20,7 @@ const createMockFile = (name: string, type: string, size: number = 1024) => {
describe('App', () => { describe('App', () => {
test('renders all main sections and upload button is initially disabled', async () => { test('renders all main sections and upload button is initially disabled', async () => {
const screen = render(App) const screen = await render(App)
await expect.element(screen.getByText('With list')).toBeInTheDocument() await expect.element(screen.getByText('With list')).toBeInTheDocument()
await expect.element(screen.getByText('With grid')).toBeInTheDocument() await expect.element(screen.getByText('With grid')).toBeInTheDocument()
@ -34,7 +34,7 @@ describe('App', () => {
}) })
test('can add and remove files and upload', async () => { test('can add and remove files and upload', async () => {
const screen = render(App) const screen = await render(App)
const fileInput = document.getElementById( const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy', 'uppy-dropzone-file-input-uppy',
@ -60,7 +60,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => { describe('ScreenCapture Component', () => {
test('renders with title, control buttons, and close functionality works', async () => { test('renders with title, control buttons, and close functionality works', async () => {
const screen = render(App) const screen = await render(App)
await screen await screen
.getByRole('button', { name: 'Screen Capture', exact: true }) .getByRole('button', { name: 'Screen Capture', exact: true })
@ -93,7 +93,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => { describe('Webcam Component', () => {
test('renders with title, control buttons, and close functionality works', async () => { test('renders with title, control buttons, and close functionality works', async () => {
const screen = render(App) const screen = await render(App)
await screen.getByRole('button', { name: 'Webcam', exact: true }).click() await screen.getByRole('button', { name: 'Webcam', exact: true }).click()
@ -124,7 +124,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => { describe('RemoteSource Component', () => {
test('renders login button and login interaction works', async () => { test('renders login button and login interaction works', async () => {
const screen = render(App) const screen = await render(App)
await screen.getByRole('button', { name: 'Dropbox', exact: true }).click() await screen.getByRole('button', { name: 'Dropbox', exact: true }).click()
@ -132,12 +132,11 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument() await expect.element(loginButton).toBeInTheDocument()
await loginButton.click() await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
}) })
}) })
test('Dashboard reacts to prop changes', async () => { test('Dashboard reacts to prop changes', async () => {
const screen = render(PropsReactivity) const screen = await render(PropsReactivity)
const toggleButton = screen.getByText('Toggle dashboard') const toggleButton = screen.getByText('Toggle dashboard')
const dashboard = screen.container.querySelector('.uppy-Dashboard') const dashboard = screen.container.querySelector('.uppy-Dashboard')
@ -148,7 +147,7 @@ test('Dashboard reacts to prop changes', async () => {
}) })
test('StatusBar reacts to prop changes', async () => { test('StatusBar reacts to prop changes', async () => {
const screen = render(PropsReactivity) const screen = await render(PropsReactivity)
const toggleButton = screen.getByText('Toggle statusbar') const toggleButton = screen.getByText('Toggle statusbar')
expect( expect(

View file

@ -1,5 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite' import { sveltekit } from '@sveltejs/kit/vite'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
@ -7,7 +8,7 @@ export default defineConfig({
test: { test: {
browser: { browser: {
enabled: true, enabled: true,
provider: 'playwright', provider: playwright(),
instances: [{ browser: 'chromium' }], instances: [{ browser: 'chromium' }],
}, },
}, },

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -140,7 +140,7 @@
This API is a one-shot upload UI using a modal overlay. Call the This API is a one-shot upload UI using a modal overlay. Call the
function and receive a listen to an event with upload results ✌️ function and receive a listen to an event with upload results ✌️
</p> </p>
<button onclick="openModal()">Open</button> <button type="button" onclick="openModal()">Open</button>
<h2>uppy.upload()</h2> <h2>uppy.upload()</h2>
<p>An &lt;input type=file&gt; backed by <code>uppy.upload()</code>:</p> <p>An &lt;input type=file&gt; backed by <code>uppy.upload()</code>:</p>

View file

@ -50,9 +50,11 @@ const formUppy = new Uppy({
}) })
.use(Transloadit, { .use(Transloadit, {
waitForEncoding: true, waitForEncoding: true,
params: { assemblyOptions: {
auth: { key: TRANSLOADIT_KEY }, params: {
template_id: TEMPLATE_ID, auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
}, },
}) })
@ -100,9 +102,11 @@ const formUppyWithDashboard = new Uppy({
}) })
.use(Transloadit, { .use(Transloadit, {
waitForEncoding: true, waitForEncoding: true,
params: { assemblyOptions: {
auth: { key: TRANSLOADIT_KEY }, params: {
template_id: TEMPLATE_ID, auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
}, },
}) })
@ -129,9 +133,11 @@ const dashboard = new Uppy({
.use(ImageEditor, { target: Dashboard }) .use(ImageEditor, { target: Dashboard })
.use(Transloadit, { .use(Transloadit, {
waitForEncoding: true, waitForEncoding: true,
params: { assemblyOptions: {
auth: { key: TRANSLOADIT_KEY }, params: {
template_id: TEMPLATE_ID, auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
}, },
}) })
@ -151,9 +157,11 @@ const dashboardModal = new Uppy({
.use(ImageEditor, { target: Dashboard }) .use(ImageEditor, { target: Dashboard })
.use(Transloadit, { .use(Transloadit, {
waitForEncoding: true, waitForEncoding: true,
params: { assemblyOptions: {
auth: { key: TRANSLOADIT_KEY }, params: {
template_id: TEMPLATE_ID, auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
}, },
}) })
@ -182,9 +190,11 @@ const uppyWithoutUI = new Uppy({
}, },
}).use(Transloadit, { }).use(Transloadit, {
waitForEncoding: true, waitForEncoding: true,
params: { assemblyOptions: {
auth: { key: TRANSLOADIT_KEY }, params: {
template_id: TEMPLATE_ID, auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
}, },
}) })

View file

@ -15,7 +15,7 @@
"@uppy/remote-sources": "workspace:*", "@uppy/remote-sources": "workspace:*",
"@uppy/transloadit": "workspace:*", "@uppy/transloadit": "workspace:*",
"@uppy/webcam": "workspace:*", "@uppy/webcam": "workspace:*",
"express": "^4.22.0", "express": "^5.2.1",
"he": "^1.2.0" "he": "^1.2.0"
}, },
"private": true, "private": true,

View file

@ -36,7 +36,7 @@ function Footer() {
} }
function FormFields(fields) { function FormFields(fields) {
// biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function Field([name, value]) { function Field([name, value]) {
if (name === 'transloadit') return '' if (name === 'transloadit') return ''
let isValueJSON = false let isValueJSON = false
@ -76,7 +76,7 @@ function FormFields(fields) {
} }
function UploadsList(uploads) { function UploadsList(uploads) {
// biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function Upload(upload) { function Upload(upload) {
return `<li>${e(upload.name)}</li>` return `<li>${e(upload.name)}</li>`
} }
@ -89,12 +89,12 @@ function UploadsList(uploads) {
} }
function ResultsList(results) { function ResultsList(results) {
// biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function Result(result) { function Result(result) {
return `<li>${e(result.name)} <a href="${result.ssl_url}" target="_blank">View</a></li>` return `<li>${e(result.name)} <a href="${result.ssl_url}" target="_blank">View</a></li>`
} }
// biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function ResultsSection(stepName) { function ResultsSection(stepName) {
return ` return `
<h2>${e(stepName)}</h2> <h2>${e(stepName)}</h2>

View file

@ -22,12 +22,13 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^4.1.6",
"@vitest/browser-playwright": "^4.1.6",
"msw": "^2.10.4", "msw": "^2.10.4",
"playwright": "1.57.0", "playwright": "1.60.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.1.11", "vite": "^7.1.11",
"vitest": "^3.2.4", "vitest": "^4.1.6",
"vitest-browser-vue": "^1.0.0" "vitest-browser-vue": "^2.1.0"
} }
} }

View file

@ -7,7 +7,7 @@
> >
<div class="flex items-center justify-center gap-4"> <div class="flex items-center justify-center gap-4">
<input v-bind="getFileInputProps()" class="hidden" /> <input v-bind="getFileInputProps()" class="hidden" />
<button <button type="button"
v-bind="getButtonProps()" v-bind="getButtonProps()"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >
@ -17,7 +17,7 @@
Device Device
</button> </button>
<button <button type="button"
@click="() => props.openModal('webcam')" @click="() => props.openModal('webcam')"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >
@ -27,7 +27,7 @@
Webcam Webcam
</button> </button>
<button <button type="button"
@click="() => props.openModal('screen-capture')" @click="() => props.openModal('screen-capture')"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >
@ -37,7 +37,7 @@
Screen Capture Screen Capture
</button> </button>
<button <button type="button"
@click="() => props.openModal('dropbox')" @click="() => props.openModal('dropbox')"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm" class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
> >

View file

@ -12,6 +12,7 @@
</div> </div>
<div class="mb-4"> <div class="mb-4">
<!-- biome-ignore lint/a11y/useAltText: alt is provided via editor.getImageProps() spread -->
<img <img
class="w-full max-h-[400px] rounded-lg border-2" class="w-full max-h-[400px] rounded-lg border-2"
v-bind="editor.getImageProps()" v-bind="editor.getImageProps()"
@ -29,31 +30,31 @@
</div> </div>
<div class="flex gap-2 flex-wrap mb-4"> <div class="flex gap-2 flex-wrap mb-4">
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getRotateButtonProps(-90)" v-bind="editor.getRotateButtonProps(-90)"
> >
-90° -90°
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getRotateButtonProps(90)" v-bind="editor.getRotateButtonProps(90)"
> >
+90° +90°
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getFlipHorizontalButtonProps()" v-bind="editor.getFlipHorizontalButtonProps()"
> >
Flip Flip
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getZoomButtonProps(0.1)" v-bind="editor.getZoomButtonProps(0.1)"
> >
+ Zoom + Zoom
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getZoomButtonProps(-0.1)" v-bind="editor.getZoomButtonProps(-0.1)"
> >
@ -62,25 +63,25 @@
</div> </div>
<div class="flex gap-2 flex-wrap mb-4"> <div class="flex gap-2 flex-wrap mb-4">
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropSquareButtonProps()" v-bind="editor.getCropSquareButtonProps()"
> >
1:1 1:1
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropLandscapeButtonProps()" v-bind="editor.getCropLandscapeButtonProps()"
> >
16:9 16:9
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropPortraitButtonProps()" v-bind="editor.getCropPortraitButtonProps()"
> >
9:16 9:16
</button> </button>
<button <button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50" class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getResetButtonProps()" v-bind="editor.getResetButtonProps()"
> >
@ -89,13 +90,13 @@
</div> </div>
<div class="flex gap-4 justify-end"> <div class="flex gap-4 justify-end">
<button <button type="button"
class="bg-gray-500 text-white px-4 py-2 rounded-md" class="bg-gray-500 text-white px-4 py-2 rounded-md"
v-bind="editor.getCancelButtonProps({ onClick: close })" v-bind="editor.getCancelButtonProps({ onClick: close })"
> >
Cancel Cancel
</button> </button>
<button <button type="button"
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300" class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
v-bind="editor.getSaveButtonProps({ onClick: close })" v-bind="editor.getSaveButtonProps({ onClick: close })"
> >

View file

@ -24,31 +24,31 @@
<track kind="captions" /> <track kind="captions" />
</video> </video>
<div class="flex gap-4 flex-wrap mt-4"> <div class="flex gap-4 flex-wrap mt-4">
<button <button type="button"
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300" class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
v-bind="primaryActionButtonProps" v-bind="primaryActionButtonProps"
> >
{{ primaryActionButtonLabel }} {{ primaryActionButtonLabel }}
</button> </button>
<button <button type="button"
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300" class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
v-bind="recordButtonProps" v-bind="recordButtonProps"
> >
Record Record
</button> </button>
<button <button type="button"
class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300" class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
v-bind="stopRecordingButtonProps" v-bind="stopRecordingButtonProps"
> >
Stop Stop
</button> </button>
<button <button type="button"
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300" class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
v-bind="submitButtonProps" v-bind="submitButtonProps"
> >
Submit Submit
</button> </button>
<button <button type="button"
class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300" class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
v-bind="discardButtonProps" v-bind="discardButtonProps"
> >

View file

@ -1,6 +1,6 @@
import { userEvent } from '@vitest/browser/context'
import { setupWorker } from 'msw/browser' import { setupWorker } from 'msw/browser'
import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-vue' import { render } from 'vitest-browser-vue'
import { tusHandlers } from '../../shared/tusHandlers.js' import { tusHandlers } from '../../shared/tusHandlers.js'
import App from '../src/App.vue' import App from '../src/App.vue'
@ -19,7 +19,7 @@ const createMockFile = (name: string, type: string, size: number = 1024) => {
describe('App', () => { describe('App', () => {
test('renders all main sections and upload button is initially disabled', async () => { test('renders all main sections and upload button is initially disabled', async () => {
const screen = render(App) const screen = await render(App)
await expect.element(screen.getByText('With list')).toBeInTheDocument() await expect.element(screen.getByText('With list')).toBeInTheDocument()
await expect.element(screen.getByText('With grid')).toBeInTheDocument() await expect.element(screen.getByText('With grid')).toBeInTheDocument()
@ -33,7 +33,7 @@ describe('App', () => {
}) })
test('can add and remove files and upload', async () => { test('can add and remove files and upload', async () => {
const screen = render(App) const screen = await render(App)
const fileInput = document.getElementById( const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy', 'uppy-dropzone-file-input-uppy',
@ -59,7 +59,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => { describe('ScreenCapture Component', () => {
test('renders with title, control buttons, and close functionality works', async () => { test('renders with title, control buttons, and close functionality works', async () => {
const screen = render(App) const screen = await render(App)
await screen await screen
.getByRole('button', { name: 'Screen Capture', exact: true }) .getByRole('button', { name: 'Screen Capture', exact: true })
@ -92,7 +92,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => { describe('Webcam Component', () => {
test('renders with title, control buttons, and close functionality works', async () => { test('renders with title, control buttons, and close functionality works', async () => {
const screen = render(App) const screen = await render(App)
await screen.getByRole('button', { name: 'Webcam', exact: true }).click() await screen.getByRole('button', { name: 'Webcam', exact: true }).click()
@ -123,7 +123,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => { describe('RemoteSource Component', () => {
test('renders login button and login interaction works', async () => { test('renders login button and login interaction works', async () => {
const screen = render(App) const screen = await render(App)
await screen.getByRole('button', { name: 'Dropbox', exact: true }).click() await screen.getByRole('button', { name: 'Dropbox', exact: true }).click()
@ -131,6 +131,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument() await expect.element(loginButton).toBeInTheDocument()
await loginButton.click() await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
}) })
}) })

View file

@ -1,5 +1,6 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
@ -7,7 +8,7 @@ export default defineConfig({
test: { test: {
browser: { browser: {
enabled: true, enabled: true,
provider: 'playwright', provider: playwright(),
instances: [{ browser: 'chromium' }], instances: [{ browser: 'chromium' }],
}, },
}, },

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -6,7 +6,7 @@
"@uppy/dashboard": "workspace:*", "@uppy/dashboard": "workspace:*",
"@uppy/xhr-upload": "workspace:*", "@uppy/xhr-upload": "workspace:*",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.22.0", "express": "^5.2.1",
"multer": "^2.0.2" "multer": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -2,7 +2,7 @@
"name": "@uppy-dev/build", "name": "@uppy-dev/build",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:", "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox, Google Drive, S3 and more :dog:",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"examples/*", "examples/*",
@ -29,7 +29,7 @@
"release": "changeset publish" "release": "changeset publish"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.5", "@biomejs/biome": "2.4.15",
"@changesets/changelog-github": "^0.5.1", "@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "patch:@changesets/cli@npm%3A2.29.5#~/.yarn/patches/@changesets-cli-npm-2.29.5-68d8030bf3.patch", "@changesets/cli": "patch:@changesets/cli@npm%3A2.29.5#~/.yarn/patches/@changesets-cli-npm-2.29.5-68d8030bf3.patch",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",

View file

@ -1,45 +0,0 @@
{
"ignorePatterns": ["projects/**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
// eslint-disable-line import/newline-after-import
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@typescript-eslint/semi": ["error", "never"],
"import/no-unresolved": "off",
"import/prefer-default-export": "off",
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"semi": ["error", "never"]
}
},
{
"files": ["*.html"],
"extends": [
"plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
],
"rules": {}
}
]
}

View file

@ -17,22 +17,15 @@
"@angular/platform-browser": "^19.2.17", "@angular/platform-browser": "^19.2.17",
"@angular/platform-browser-dynamic": "^19.2.17", "@angular/platform-browser-dynamic": "^19.2.17",
"@angular/router": "^19.2.17", "@angular/router": "^19.2.17",
"rxjs": "~7.8.0", "rxjs": "~7.8.2",
"tslib": "^2.3.0", "tslib": "^2.8.1",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.0.0", "@angular-devkit/build-angular": "^19.0.0",
"@angular-eslint/eslint-plugin": "^18.0.1",
"@angular-eslint/eslint-plugin-template": "^18.0.1",
"@angular/cli": "^19.0.0", "@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.2.17", "@angular/compiler-cli": "^19.2.17",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@typescript-eslint/types": "^7.2.0",
"@typescript-eslint/utils": "^7.2.0",
"eslint": "^8.57.0",
"jasmine-core": "~5.1.0", "jasmine-core": "~5.1.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",

View file

@ -1,46 +0,0 @@
{
"extends": "../../../.eslintrc.json",
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"project": [
"packages/@uppy/angular/projects/angular/tsconfig.lib.json",
"packages/@uppy/angular/projects/angular/tsconfig.spec.json"
],
"createDefaultProgram": true
},
"rules": {
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "uppy",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "uppy",
"style": "camelCase"
}
],
"dot-notation": "error",
"indent": "error",
"no-empty-function": "off",
"no-shadow": "error",
"no-unused-expressions": "error",
"no-use-before-define": "off",
"quotes": "error",
"semi": "error"
}
},
{
"files": ["*.html"],
"rules": {}
}
]
}

View file

@ -22,7 +22,7 @@
"prepublishOnly": "rm -fr * && cp -r ../../../dist/uppy/angular .." "prepublishOnly": "rm -fr * && cp -r ../../../dist/uppy/angular .."
}, },
"dependencies": { "dependencies": {
"tslib": "^2.0.0" "tslib": "^2.8.1"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0", "@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",

View file

@ -41,16 +41,16 @@
}, },
"dependencies": { "dependencies": {
"@uppy/utils": "workspace:^", "@uppy/utils": "workspace:^",
"preact": "^10.26.10" "preact": "^10.29.2"
}, },
"devDependencies": { "devDependencies": {
"cssnano": "^7.0.7", "cssnano": "^8.0.1",
"jsdom": "^26.1.0", "jsdom": "^29.1.1",
"postcss": "^8.5.6", "postcss": "^8.5.15",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"sass": "^1.89.2", "sass": "^1.89.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4" "vitest": "^4.1.6"
}, },
"peerDependencies": { "peerDependencies": {
"@uppy/core": "workspace:^" "@uppy/core": "workspace:^"

View file

@ -284,7 +284,9 @@ export default class Audio<M extends Meta, B extends Body> extends UIPlugin<
#stop = async () => { #stop = async () => {
if (this.#stream) { if (this.#stream) {
const audioTracks = this.#stream.getAudioTracks() const audioTracks = this.#stream.getAudioTracks()
audioTracks.forEach((track) => track.stop()) audioTracks.forEach((track) => {
track.stop()
})
} }
if (this.#recorder) { if (this.#recorder) {

View file

@ -44,11 +44,12 @@
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.362.0", "@aws-sdk/client-s3": "^3.362.0",
"@aws-sdk/s3-request-presigner": "^3.362.0", "@aws-sdk/s3-request-presigner": "^3.362.0",
"@types/node": "^20.19.0",
"@uppy/core": "workspace:^", "@uppy/core": "workspace:^",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"nock": "^13.1.0", "nock": "^13.1.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4", "vitest": "^4.1.6",
"whatwg-fetch": "3.6.2" "whatwg-fetch": "3.6.2"
}, },
"peerDependencies": { "peerDependencies": {

View file

@ -263,9 +263,9 @@ export class HTTPCommunicationQueue<M extends Meta, B extends Body> {
const data = chunk.getData() const data = chunk.getData()
if (method.toUpperCase() === 'POST') { if (method.toUpperCase() === 'POST') {
const formData = new FormData() const formData = new FormData()
Object.entries(fields!).forEach(([key, value]) => Object.entries(fields!).forEach(([key, value]) => {
formData.set(key, value), formData.set(key, value)
) })
formData.set('file', data) formData.set('file', data)
body = formData body = formData
} else { } else {

View file

@ -256,6 +256,7 @@ class MultipartUploader<M extends Meta, B extends Body> {
else this.pause() else this.pause()
} }
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via Symbol in tests
private [Symbol.for('uppy test: getChunkState')]() { private [Symbol.for('uppy test: getChunkState')]() {
return this.#chunkState return this.#chunkState
} }

View file

@ -662,10 +662,11 @@ export default class AwsS3Multipart<
querify: true, querify: true,
}) })
const query = new URLSearchParams({ filename, type, ...metadata } as Record< const query = new URLSearchParams({
string, filename,
string ...(type != null && { type }),
>) ...metadata,
})
return this.#client.get(`s3/params?${query}`, options) return this.#client.get(`s3/params?${query}`, options)
} }

View file

@ -2,7 +2,8 @@
"extends": "../../../tsconfig.shared", "extends": "../../../tsconfig.shared",
"compilerOptions": { "compilerOptions": {
"emitDeclarationOnly": false, "emitDeclarationOnly": false,
"noEmit": true "noEmit": true,
"types": ["preact", "node"]
}, },
"include": ["./package.json", "./src/**/*.*"], "include": ["./package.json", "./src/**/*.*"],
"references": [ "references": [

View file

@ -37,7 +37,7 @@
"@uppy/companion-client": "workspace:^", "@uppy/companion-client": "workspace:^",
"@uppy/provider-views": "workspace:^", "@uppy/provider-views": "workspace:^",
"@uppy/utils": "workspace:^", "@uppy/utils": "workspace:^",
"preact": "^10.26.10" "preact": "^10.29.2"
}, },
"peerDependencies": { "peerDependencies": {
"@uppy/core": "workspace:^" "@uppy/core": "workspace:^"

View file

@ -41,9 +41,9 @@
"p-retry": "^6.1.0" "p-retry": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^26.1.0", "jsdom": "^29.1.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4" "vitest": "^4.1.6"
}, },
"peerDependencies": { "peerDependencies": {
"@uppy/core": "workspace:^" "@uppy/core": "workspace:^"

View file

@ -5,8 +5,11 @@ import type {
UnknownProviderPlugin, UnknownProviderPlugin,
Uppy, Uppy,
} from '@uppy/core' } from '@uppy/core'
import type { CompanionClientProvider, RequestOptions } from '@uppy/utils' import {
import { isOriginAllowed } from './getAllowedHosts.js' type CompanionClientProvider,
getSocketHost,
type RequestOptions,
} from '@uppy/utils'
import type { CompanionPluginOptions } from './index.js' import type { CompanionPluginOptions } from './index.js'
import RequestClient, { authErrorStatusCode } from './RequestClient.js' import RequestClient, { authErrorStatusCode } from './RequestClient.js'
@ -135,22 +138,27 @@ export default class Provider<M extends Meta, B extends Body>
authUrl({ authUrl({
authFormData, authFormData,
query, query,
authCallbackToken,
}: { }: {
authFormData: unknown authFormData: unknown
query: Record<string, string> query: Record<string, string>
authCallbackToken?: string | undefined
}): string { }): string {
const params = new URLSearchParams({ const searchParams = new URLSearchParams({
...query, ...query,
// This is only used for Companion instances configured to accept multiple origins. // `origin` is only used for Companion instances configured to accept multiple origins.
state: btoa(JSON.stringify({ origin: getOrigin() })), state: btoa(JSON.stringify({ origin: getOrigin() })),
...this.authQuery({ authFormData }), ...this.authQuery({ authFormData }),
}) })
if (this.preAuthToken) { if (this.preAuthToken) {
params.set('uppyPreAuthToken', this.preAuthToken) searchParams.set('uppyPreAuthToken', this.preAuthToken)
}
if (authCallbackToken) {
searchParams.set('authCallbackToken', authCallbackToken)
} }
return `${this.hostname}/${this.id}/connect?${params}` return `${this.hostname}/${this.id}/connect?${searchParams}`
} }
protected async loginSimpleAuth({ protected async loginSimpleAuth({
@ -181,67 +189,62 @@ export default class Provider<M extends Meta, B extends Body>
signal: AbortSignal signal: AbortSignal
}): Promise<void> { }): Promise<void> {
await this.ensurePreAuth() await this.ensurePreAuth()
signal.throwIfAborted() signal.throwIfAborted()
const link = this.authUrl({ query: { uppyVersions }, authFormData }) // Important: We need to do this synchronously, or else browsers might block the popup.
// (We cannot wait until the websocket connection is established).
// This opens up a race condtition if the websocket is not connected before the user
// completes(or cancels) authentication, but thats a small compromose we gotta make.
const authCallbackToken = crypto.randomUUID()
const link = this.authUrl({
query: { uppyVersions },
authFormData,
authCallbackToken,
})
const authWindow = window.open(link, '_blank') const authWindow = window.open(link, '_blank')
let interval: number | undefined let interval: number | undefined
let handleMessage: ((e: MessageEvent<any>) => void) | undefined let webSocket: WebSocket | undefined
try { try {
return await new Promise((resolve, reject) => { const host = getSocketHost(this.opts.companionUrl)
handleMessage = (e: MessageEvent<any>) => {
if (e.source !== authWindow) { // Note that this promise is not guaranteed to settle in all cases
let jsonData = '' const token = await new Promise<string>((resolve, reject) => {
try { webSocket = new WebSocket(
// TODO improve our uppy logger so that it can take an arbitrary number of arguments, `${host}/api2/auth-callback/token/${authCallbackToken}`,
// each either objects, errors or strings, )
// then we dont have to manually do these things like json stringify when logging.
// the logger should never throw an error. webSocket.addEventListener('close', () => {
jsonData = JSON.stringify(e.data) reject(new Error('Socket closed'))
} catch (_err) { })
// in case JSON.stringify fails (ignored)
webSocket.addEventListener('error', (error) => {
this.uppy.log(
`Companion socket error ${JSON.stringify(error)}, closing socket`,
'warning',
)
webSocket?.close() // 'close' event will be emitted
})
webSocket.addEventListener('message', (e) => {
try {
const { token, error } = JSON.parse(e.data)
if (error) {
reject(new Error('Authentication reported error'))
} else if (!token) {
reject(new Error('Authentication did not return a token'))
} else {
resolve(token)
} }
this.uppy.log( } catch (err) {
`ignoring event from unknown source ${jsonData}`, reject(err)
'warning',
)
return
} }
})
const { companionAllowedHosts } = this.#getPlugin().opts signal.addEventListener('abort', () =>
if (!isOriginAllowed(e.origin, companionAllowedHosts)) { reject(new Error('Authentication was aborted')),
this.uppy.log( )
`ignoring event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`,
'warning',
)
// We cannot reject here because the page might send events from other origins
// before sending the "real" auth completed event.
// for example Box has a "Pendo" tool that sends events to the opener
// https://github.com/transloadit/uppy/pull/5719
return
}
// Check if it's a string before doing the JSON.parse to maintain support
// for older Companion versions that used object references
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
if (data.error) {
const { uppy } = this
const message = uppy.i18n('authAborted')
uppy.info({ message }, 'warning', 5000)
reject(new Error('auth aborted'))
return
}
if (!data.token) {
reject(new Error('did not receive token from auth window'))
return
}
resolve(this.setAuthToken(data.token))
}
// poll for user closure of the window, so we can reject when it happens // poll for user closure of the window, so we can reject when it happens
if (authWindow) { if (authWindow) {
@ -251,15 +254,21 @@ export default class Provider<M extends Meta, B extends Body>
} }
}, 500) }, 500)
} }
signal.addEventListener('abort', () => reject(new Error('Aborted')))
window.addEventListener('message', handleMessage)
}) })
this.setAuthToken(token)
} catch (err) {
const message = this.uppy.i18n('authAborted')
this.uppy.info({ message }, 'warning', 5000)
this.uppy.log(`Authentication failed: ${err.message}`, 'warning')
throw err
} finally { } finally {
// cleanup: // cleanup:
authWindow?.close() // if we don't setTimeout, the window doesn't really close (I don't know why).
window.clearInterval(interval) setTimeout(() => authWindow?.close(), 1)
if (handleMessage) window.removeEventListener('message', handleMessage) this.uppy.log(`Closing auth callback socket ${authCallbackToken}`)
webSocket?.close()
clearInterval(interval)
} }
} }

View file

@ -234,12 +234,13 @@ export default class RequestClient<M extends Meta, B extends Body> {
} }
/** /**
* Remote uploading consists of two steps: * Remote uploading uses a single queue admission per attempt:
* 1. #requestSocketToken which starts the download/upload in companion and returns a unique token for the upload. * 1. Acquire one queue slot.
* Then companion will halt the upload until: * 2. Reuse an existing serverToken or request a new one from Companion.
* 2. #awaitRemoteFileUpload is called, which will open/ensure a websocket connection towards companion, with the * 3. Open/maintain the websocket and wait for Companion to finish.
* previously generated token provided. It returns a promise that will resolve/reject once the file has finished *
* uploading or is otherwise done (failed, canceled) * This prevents socket tokens from being created long before their websocket
* session is admitted by the queue.
*/ */
async uploadRemoteFile( async uploadRemoteFile(
file: RemoteUppyFile<M, B>, file: RemoteUppyFile<M, B>,
@ -248,72 +249,46 @@ export default class RequestClient<M extends Meta, B extends Body> {
): Promise<void> { ): Promise<void> {
try { try {
const { signal, getQueue } = options || {} const { signal, getQueue } = options || {}
const queue = getQueue()
return await pRetry( return await pRetry(
async () => { async () => {
// if we already have a serverToken, assume that we are resuming the existing server upload id const queueRemoteUploadAttempt = queue.wrapPromiseFunction(
const existingServerToken = this.uppy.getFile(file.id)?.serverToken async () => {
if (existingServerToken != null) { const currentFile = this.uppy.getFile(file.id) as
this.uppy.log( | RemoteUppyFile<M, B>
`Connecting to exiting websocket ${existingServerToken}`, | undefined
) if (currentFile == null) return undefined
return this.#awaitRemoteFileUpload({
file,
queue: getQueue(),
signal,
})
}
const queueRequestSocketToken = getQueue().wrapPromiseFunction( let serverToken = currentFile.serverToken
async (
...args: [
{
file: RemoteUppyFile<M, B>
postBody: Record<string, unknown>
signal: AbortSignal
},
]
) => {
try {
return await this.#requestSocketToken(...args)
} catch (outerErr) {
// throwing AbortError will cause p-retry to stop retrying
if (outerErr.isAuthError) throw new AbortError(outerErr)
if (outerErr.cause == null) throw outerErr if (serverToken != null) {
const err = outerErr.cause this.uppy.log(`Connecting to exiting websocket ${serverToken}`)
} else {
serverToken = await this.#requestSocketTokenWithRetryStrategy({
file: currentFile,
postBody: reqBody,
signal,
})
const isRetryableHttpError = () => if (!this.uppy.getFile(file.id)) return undefined
[408, 409, 429, 418, 423].includes(err.statusCode) ||
(err.statusCode >= 500 &&
err.statusCode <= 599 &&
![501, 505].includes(err.statusCode))
if (err.name === 'HttpError' && !isRetryableHttpError())
throw new AbortError(err)
// p-retry will retry most other errors, this.uppy.setFileState(file.id, { serverToken })
// but it will not retry TypeError (except network error TypeErrors)
throw err
} }
const latestFile = this.uppy.getFile(file.id) as
| RemoteUppyFile<M, B>
| undefined
if (latestFile == null) return undefined
return this.#awaitRemoteFileUpload({
file: latestFile,
signal,
})
}, },
{ priority: -1 },
) )
const serverToken = await queueRequestSocketToken({ return queueRemoteUploadAttempt().abortOn(signal)
file,
postBody: reqBody,
signal,
}).abortOn(signal)
if (!this.uppy.getFile(file.id)) return undefined // has file since been removed?
this.uppy.setFileState(file.id, { serverToken })
return this.#awaitRemoteFileUpload({
file: this.uppy.getFile(file.id) as RemoteUppyFile<M, B>, // re-fetching file because it might have changed in the meantime
queue: getQueue(),
signal,
})
}, },
{ {
retries: retryCount, retries: retryCount,
@ -360,6 +335,39 @@ export default class RequestClient<M extends Meta, B extends Body> {
return res.token return res.token
} }
#requestSocketTokenWithRetryStrategy = async ({
file,
postBody,
signal,
}: {
file: RemoteUppyFile<M, B>
postBody: Record<string, unknown>
signal: AbortSignal
}): Promise<string> => {
try {
return await this.#requestSocketToken({ file, postBody, signal })
} catch (outerErr) {
// throwing AbortError will cause p-retry to stop retrying
if (outerErr.isAuthError) throw new AbortError(outerErr)
if (outerErr.cause == null) throw outerErr
const err = outerErr.cause
const isRetryableHttpError = () =>
[408, 409, 429, 418, 423].includes(err.statusCode) ||
(err.statusCode >= 500 &&
err.statusCode <= 599 &&
![501, 505].includes(err.statusCode))
if (err.name === 'HttpError' && !isRetryableHttpError()) {
throw new AbortError(err)
}
// p-retry will retry most other errors,
// but it will not retry TypeError (except network error TypeErrors)
throw err
}
}
/** /**
* This method will ensure a websocket for the specified file and returns a promise that resolves * This method will ensure a websocket for the specified file and returns a promise that resolves
* when the file has finished downloading, or rejects if it fails. * when the file has finished downloading, or rejects if it fails.
@ -367,11 +375,9 @@ export default class RequestClient<M extends Meta, B extends Body> {
*/ */
async #awaitRemoteFileUpload({ async #awaitRemoteFileUpload({
file, file,
queue,
signal, signal,
}: { }: {
file: RemoteUppyFile<M, B> file: RemoteUppyFile<M, B>
queue: any
signal: AbortSignal signal: AbortSignal
}): Promise<void> { }): Promise<void> {
let removeEventHandlers: () => void let removeEventHandlers: () => void
@ -430,126 +436,113 @@ export default class RequestClient<M extends Meta, B extends Body> {
function resetActivityTimeout() { function resetActivityTimeout() {
clearTimeout(activityTimeout) clearTimeout(activityTimeout)
if (isPaused) return if (isPaused) return
activityTimeout = setTimeout( activityTimeout = setTimeout(() => {
() => onFatalError(
onFatalError( new Error('Timeout waiting for message from Companion socket'),
new Error( )
'Timeout waiting for message from Companion socket', }, socketActivityTimeoutMs)
),
),
socketActivityTimeoutMs,
)
} }
try { try {
await queue const reconnectWebsocket = async () =>
.wrapPromiseFunction(async () => { new Promise((_, rejectSocket) => {
const reconnectWebsocket = async () => socket = new WebSocket(`${host}/api/${token}`)
new Promise((_, rejectSocket) => {
socket = new WebSocket(`${host}/api/${token}`)
resetActivityTimeout() resetActivityTimeout()
socket.addEventListener('close', () => { socket.addEventListener('close', () => {
socket = undefined socket = undefined
rejectSocket(new Error('Socket closed unexpectedly')) rejectSocket(new Error('Socket closed unexpectedly'))
})
socket.addEventListener('error', (error) => {
this.uppy.log(
`Companion socket error ${JSON.stringify(
error,
)}, closing socket`,
'warning',
)
socket?.close() // will 'close' event to be emitted
})
socket.addEventListener('open', () => {
sendState()
})
socket.addEventListener('message', (e) => {
resetActivityTimeout()
try {
const { action, payload } = JSON.parse(e.data)
switch (action) {
case 'progress': {
emitSocketProgress(
this,
payload,
this.uppy.getFile(file.id),
)
break
}
case 'success': {
// payload.response is sent from companion for xhr-upload (aka uploadMultipart in companion) and
// s3 multipart (aka uploadS3Multipart)
// but not for tus/transloadit (aka uploadTus)
// responseText is a string which may or may not be in JSON format
// this means that an upload destination of xhr or s3 multipart MUST respond with valid JSON
// to companion, or the JSON.parse will crash
const text = payload.response?.responseText
this.uppy.emit(
'upload-success',
this.uppy.getFile(file.id),
{
uploadURL: payload.url,
status: payload.response?.status ?? 200,
body: text
? (JSON.parse(text) as B)
: undefined,
},
)
socketAbortController?.abort?.()
resolve()
break
}
case 'error': {
const { message } = payload.error
throw Object.assign(new Error(message), {
cause: payload.error,
})
}
default:
this.uppy.log(
`Companion socket unknown action ${action}`,
'warning',
)
}
} catch (err) {
onFatalError(err)
}
})
const closeSocket = () => {
this.uppy.log(`Closing socket ${file.id}`)
clearTimeout(activityTimeout)
if (socket) socket.close()
socket = undefined
}
socketAbortController.signal.addEventListener(
'abort',
() => {
closeSocket()
},
)
})
await pRetry(reconnectWebsocket, {
retries: retryCount,
signal: socketAbortController.signal,
onFailedAttempt: () => {
if (socketAbortController.signal.aborted) return // don't log in this case
this.uppy.log(`Retrying websocket ${file.id}`)
},
}) })
})()
.abortOn(socketAbortController.signal) socket.addEventListener('error', (error) => {
this.uppy.log(
`Companion socket error ${JSON.stringify(
error,
)}, closing socket`,
'warning',
)
socket?.close() // will 'close' event to be emitted
})
socket.addEventListener('open', () => {
sendState()
})
socket.addEventListener('message', (e) => {
resetActivityTimeout()
try {
const { action, payload } = JSON.parse(e.data)
switch (action) {
case 'progress': {
emitSocketProgress(
this,
payload,
this.uppy.getFile(file.id),
)
break
}
case 'success': {
// payload.response is sent from companion for xhr-upload (aka uploadMultipart in companion) and
// s3 multipart (aka uploadS3Multipart)
// but not for tus/transloadit (aka uploadTus)
// responseText is a string which may or may not be in JSON format
// this means that an upload destination of xhr or s3 multipart MUST respond with valid JSON
// to companion, or the JSON.parse will crash
const text = payload.response?.responseText
this.uppy.emit(
'upload-success',
this.uppy.getFile(file.id),
{
uploadURL: payload.url,
status: payload.response?.status ?? 200,
body: text ? (JSON.parse(text) as B) : undefined,
},
)
socketAbortController?.abort?.()
resolve()
break
}
case 'error': {
const { message } = payload.error
throw Object.assign(new Error(message), {
cause: payload.error,
})
}
default:
this.uppy.log(
`Companion socket unknown action ${action}`,
'warning',
)
}
} catch (err) {
onFatalError(err)
}
})
const closeSocket = () => {
this.uppy.log(`Closing socket ${file.id}`)
clearTimeout(activityTimeout)
if (socket) socket.close()
socket = undefined
}
socketAbortController.signal.addEventListener('abort', () => {
closeSocket()
})
})
await pRetry(reconnectWebsocket, {
retries: retryCount,
signal: socketAbortController.signal,
onFailedAttempt: () => {
if (socketAbortController.signal.aborted) return // don't log in this case
this.uppy.log(`Retrying websocket ${file.id}`)
},
})
} catch (err) { } catch (err) {
if (socketAbortController.signal.aborted) return if (socketAbortController.signal.aborted) return
onFatalError(err) onFatalError(err)

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import getAllowedHosts, { isOriginAllowed } from './getAllowedHosts.js' import getAllowedHosts from './getAllowedHosts.js'
describe('getAllowedHosts', () => { describe('getAllowedHosts', () => {
it('can convert companionAllowedHosts', () => { it('can convert companionAllowedHosts', () => {
@ -30,20 +30,3 @@ describe('getAllowedHosts', () => {
) )
}) })
}) })
describe('isOriginAllowed', () => {
it('should check origin', () => {
expect(isOriginAllowed('a', [/^.+$/])).toBeTruthy()
expect(isOriginAllowed('a', ['^.+$'])).toBeTruthy()
expect(
isOriginAllowed('www.transloadit.com', ['^www\\.transloadit\\.com$']),
).toBeTruthy()
expect(
isOriginAllowed('www.transloadit.com', ['^transloadit\\.com$']),
).toBeFalsy()
expect(isOriginAllowed('match', ['fail', 'match'])).toBeTruthy()
expect(
isOriginAllowed('www.transloadit.com', ['\\.transloadit\\.com$']),
).toBeTruthy()
})
})

View file

@ -47,15 +47,3 @@ export default function getAllowedHosts(
ret = escapeRegex(ret) ret = escapeRegex(ret)
return ret return ret
} }
export function isOriginAllowed(
origin: string,
allowedOrigin: string | RegExp | Array<string | RegExp> | undefined,
): boolean {
const patterns = Array.isArray(allowedOrigin)
? allowedOrigin.map(wrapInRegex)
: [wrapInRegex(allowedOrigin)]
return patterns.some(
(pattern) => pattern?.test(origin) || pattern?.test(`${origin}/`),
) // allowing for trailing '/'
}

View file

@ -34,6 +34,6 @@ test/output/*
.DS_Store .DS_Store
# Transpiled # Transpiled
./lib/ ./dist/
infra/kube/companion/uppy-env.yaml infra/kube/companion/uppy-env.yaml
scripts/.tl-deploy-hosts-danger.txt scripts/.tl-deploy-hosts-danger.txt

View file

@ -1,5 +1,12 @@
# @uppy/companion # @uppy/companion
## 6.2.2
### Patch Changes
- 49db42d: Fix bug with 429 not returning JSON response with message
- 4652dc6: upgrade @aws-sdk/ deps in @uppy/companion
## 6.2.1 ## 6.2.1
### Patch Changes ### Patch Changes

View file

@ -38,8 +38,6 @@ data:
COMPANION_BOX_SECRET: 'YOUR BOX SECRET' COMPANION_BOX_SECRET: 'YOUR BOX SECRET'
COMPANION_GOOGLE_KEY: 'YOUR GOOGLE KEY' COMPANION_GOOGLE_KEY: 'YOUR GOOGLE KEY'
COMPANION_GOOGLE_SECRET: 'YOUR GOOGLE SECRET' COMPANION_GOOGLE_SECRET: 'YOUR GOOGLE SECRET'
COMPANION_INSTAGRAM_KEY: 'YOUR INSTAGRAM KEY'
COMPANION_INSTAGRAM_SECRET: 'YOUR INSTAGRAM SECRET'
COMPANION_AWS_KEY: 'YOUR AWS KEY' COMPANION_AWS_KEY: 'YOUR AWS KEY'
COMPANION_AWS_SECRET: 'YOUR AWS SECRET' COMPANION_AWS_SECRET: 'YOUR AWS SECRET'
COMPANION_AWS_BUCKET: 'YOUR AWS S3 BUCKET' COMPANION_AWS_BUCKET: 'YOUR AWS S3 BUCKET'

View file

@ -8,7 +8,7 @@ Companion is a server integration for
[Uppy](https://github.com/transloadit/uppy) file uploader. [Uppy](https://github.com/transloadit/uppy) file uploader.
It handles the server-to-server communication between your server and file It handles the server-to-server communication between your server and file
storage providers such as Google Drive, Dropbox, Instagram, etc. **Companion is storage providers such as Google Drive, Dropbox, etc. **Companion is
not a target to upload files to**. For this, use a <https://tus.io> server (if not a target to upload files to**. For this, use a <https://tus.io> server (if
you want resumable) or your existing Apache/Nginx server (if you dont). you want resumable) or your existing Apache/Nginx server (if you dont).
[See here for full documentation](https://uppy.io/docs/companion/) [See here for full documentation](https://uppy.io/docs/companion/)
@ -68,7 +68,10 @@ on, you call the `socket` method like so.
// ... // ...
const server = app.listen(PORT) const server = app.listen(PORT)
companion.socket(server) // Pass the same `options` object you passed to `companion.app(options)`
// `companion.socket` needs `options.server` to compute the external base path
// for incoming WS URLs (important behind reverse proxies).
companion.socket(server, options)
``` ```
### Run as standalone server ### Run as standalone server

View file

@ -3,7 +3,14 @@ class Gauge {
} }
export default function () { export default function () {
const middleware = (req, res, next) => { type Req = { url?: string }
type Res = {
setHeader: (key: string, value: string) => void
end: (s?: string) => void
}
type Next = () => void
const middleware = (req: Req, res: Res, next: Next) => {
// simulate prometheus metrics endpoint: // simulate prometheus metrics endpoint:
if (req.url === '/metrics') { if (req.url === '/metrics') {
res.setHeader('Content-Type', 'text/plain') res.setHeader('Content-Type', 'text/plain')

View file

@ -1,21 +0,0 @@
export class Upload {
constructor(file, options) {
this.url = 'https://tus.endpoint/files/foo-bar'
this.options = options
}
_triggerProgressThenSuccess() {
this.options.onProgress(this.options.uploadSize, this.options.uploadSize)
setTimeout(() => this.options.onSuccess(), 100)
}
start() {
setTimeout(this._triggerProgressThenSuccess.bind(this), 100)
}
abort() {
// noop
}
}
export default { Upload }

View file

@ -0,0 +1,42 @@
type UploadOptions = {
uploadSize: number
onProgress: (bytesUploaded: number, bytesTotal: number) => void
onSuccess: () => void
} & Record<string, unknown>
let lastUploadFile: unknown
export function __getLastUploadFile(): unknown {
return lastUploadFile
}
export function __resetTusMockState(): void {
lastUploadFile = undefined
}
export class Upload {
url: string
options: UploadOptions
constructor(file: unknown, options: UploadOptions) {
lastUploadFile = file
this.url = 'https://tus.endpoint/files/foo-bar'
this.options = options
}
_triggerProgressThenSuccess() {
this.options.onProgress(this.options.uploadSize, this.options.uploadSize)
setTimeout(() => this.options.onSuccess(), 100)
}
start() {
setTimeout(this._triggerProgressThenSuccess.bind(this), 100)
}
abort() {
// noop
}
}
export default { Upload }

View file

@ -1,3 +0,0 @@
#!/usr/bin/env node
import '../lib/standalone/start-server.js'

View file

@ -26,10 +26,6 @@ COMPANION_GOOGLE_KEY=
COMPANION_GOOGLE_SECRET= COMPANION_GOOGLE_SECRET=
COMPANION_GOOGLE_SECRET_FILE= COMPANION_GOOGLE_SECRET_FILE=
COMPANION_INSTAGRAM_KEY=
COMPANION_INSTAGRAM_SECRET=
COMPANION_INSTAGRAM_SECRET_FILE=
COMPANION_FACEBOOK_KEY= COMPANION_FACEBOOK_KEY=
COMPANION_FACEBOOK_SECRET= COMPANION_FACEBOOK_SECRET=
COMPANION_FACEBOOK_SECRET_FILE= COMPANION_FACEBOOK_SECRET_FILE=

Some files were not shown because too many files have changed in this diff Show more