Merge remote-tracking branch 'origin/main' into restore/pr-6284

This commit is contained in:
prakash 2026-06-20 01:37:42 +05:30
commit 5585a796af
No known key found for this signature in database
406 changed files with 15280 additions and 11993 deletions

View file

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

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

@ -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_SECRET=***
COMPANION_INSTAGRAM_KEY=***
COMPANION_INSTAGRAM_SECRET=***
COMPANION_FACEBOOK_KEY=***
COMPANION_FACEBOOK_SECRET=***

View file

@ -61,7 +61,7 @@ This is not needed for changing existing components.
## Companion
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`
and adjust them based on what you need to work on), and run:
@ -82,7 +82,7 @@ when files are changed.
### How the Authentication and Token mechanism works
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
throughout this section.

View file

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

View file

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

View file

@ -38,56 +38,49 @@ jobs:
path: /tmp/companion-${{ github.sha }}.tar.gz
docker:
name: DockerHub
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: 0
COMPOSE_DOCKER_CLI_BUILD: 0
name: DockerHub (${{ matrix.platform }})
strategy:
matrix:
include:
- 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:
- name: Checkout sources
uses: actions/checkout@v6
- name: Docker meta
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
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{secrets.DOCKER_USERNAME}}
password: ${{secrets.DOCKER_PASSWORD}}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
file: Dockerfile
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: transloadit/companion:edge-${{ matrix.platform_tag }}
heroku:
name: Heroku
docker-manifest:
name: DockerHub manifest
needs: docker
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v6
- name: Alter dockerfile
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
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: companion-demo
heroku_email: ${{secrets.HEROKU_EMAIL}}
usedocker: true
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Publish edge manifest
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:
- name: Checkout sources
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
uses: actions/setup-node@v6
with:

View file

@ -50,7 +50,7 @@ jobs:
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d
with:
version: corepack yarn run version
publish: corepack yarn run release
@ -102,14 +102,14 @@ jobs:
# set latest tag for default branch
type=raw,value=latest,enable=true
- 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
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{secrets.DOCKER_USERNAME}}
password: ${{secrets.DOCKER_PASSWORD}}
- name: Build and push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: true
context: .

View file

@ -9,5 +9,6 @@ initScope: uppy
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
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.
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/>.
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
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

View file

@ -46,7 +46,7 @@ yarn workspace @uppy/[package-name] build
- **Uppy Core** (`@uppy/core`) - Main class that manages plugins, state, and events
- **Plugins** - Modular components for different functionalities:
- **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.
- **Utility Plugins**: Golden Retriever (recovery), Thumbnail Generator, etc.

View file

@ -22,13 +22,12 @@ FROM node:22.18.0-alpine
WORKDIR /app
# copy required files from build stage.
COPY --from=build /app/packages/@uppy/companion/bin /app/bin
COPY --from=build /app/packages/@uppy/companion/lib /app/lib
COPY --from=build /app/packages/@uppy/companion/dist /app/dist
COPY --from=build /app/packages/@uppy/companion/package.json /app/package.json
COPY --from=build /app/packages/@uppy/companion/node_modules /app/node_modules
ENV PATH "${PATH}:/app/node_modules/.bin"
CMD ["node","/app/bin/companion"]
CMD ["node","/app/dist/bin/companion.js"]
# This can be overruled later
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.
- **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
- **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:
- Resumable file uploads via the open [tus](https://tus.io/) standard, so large
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
via [@uppy/companion](https://uppy.io/docs/companion)
- Works great with file encoding and processing backends, such as
@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus
```
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
supports it.
@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
```html
<!-- 1. Add CSS to `<head>` -->
<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"
/>
@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
Uppy,
Dashboard,
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()
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
- [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
- [React](https://uppy.io/docs/react/) — components to integrate Uppy UI plugins
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,
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/))
### Sources
@ -166,8 +166,6 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
Google Drive
- ⓒ [`Dropbox`](https://uppy.io/docs/dropbox/) — import files from Dropbox
- ⓒ [`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
- ⓒ [`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 allows cropping images before uploading.
- 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
from those and push it to the destination without downloading it to your
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) 👌🏼
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).
## 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": {
"enabled": true,
"clientKind": "git",
@ -14,10 +14,11 @@
"bin/**",
"e2e/**",
"private/**",
"!packages/**/{dist,lib}/**",
"!packages/**/{dist,lib}",
"!node_modules",
"!.svelte-kit",
"!packages/@uppy/components/src/input.css"
"!packages/@uppy/components/src/input.css",
"!examples/angular/**/*.html"
]
},
"formatter": {
@ -25,20 +26,11 @@
"indentStyle": "space",
"formatWithErrors": true
},
"html": {
"experimentalFullSupportEnabled": true
},
"linter": {
"enabled": true,
"includes": [
"packages/**",
"examples/**",
"scripts/**",
"private/**",
"!packages/**/{dist,lib}/**",
"!node_modules",
"!.svelte-kit",
"!packages/@uppy/components/src/input.css",
"!**/*.vue",
"!**/*.svelte"
],
"rules": {
"recommended": true,
"suspicious": {
@ -48,22 +40,23 @@
"useExhaustiveDependencies": "error",
"useHookAtTopLevel": "error",
"noUnusedFunctionParameters": "off",
"noUnusedImports": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": {
"level": "error",
"options": {
"ignoreRestSiblings": true
}
}
},
"noNestedComponentDefinitions": "error",
"noReactPropAssignments": "error"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"nursery": {
"noNestedComponentDefinitions": "error",
"noReactPropAssign": "error"
"noSvgWithoutTitle": "off",
"useSemanticElements": "off"
}
}
},
@ -80,5 +73,36 @@
"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>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

View file

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

View file

@ -66,4 +66,4 @@ const server = app.listen(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",
"body-parser": "^1.20.4",
"dotenv": "^16.0.0",
"express": "^4.22.0"
"express": "^5.2.1"
}
}

View file

@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Uppy AWS S3 upload example</title>

View file

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

View file

@ -11,14 +11,14 @@
</head>
<body>
<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">
import {
Uppy,
Dashboard,
Webcam,
Tus,
Uppy,
Webcam,
} from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs'
const uppy = new Uppy({ debug: true, autoProceed: false })

View file

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

View file

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

View file

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

View file

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

View file

@ -10,23 +10,18 @@
/>
</head>
<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>
<script type="module">
import {
Uppy,
Dashboard,
Instagram,
GoogleDrive,
Tus,
Uppy,
} from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs'
const uppy = new Uppy({ debug: true, autoProceed: false })
.use(Dashboard, { trigger: '#uppyModalOpener' })
.use(Instagram, {
target: Dashboard,
companionUrl: 'http://localhost:3020',
})
.use(GoogleDrive, {
target: Dashboard,
companionUrl: 'http://localhost:3020',

View file

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

View file

@ -32,10 +32,6 @@ const companionOptions = {
key: 'your google key',
secret: 'your google secret',
},
instagram: {
key: 'your instagram key',
secret: 'your instagram secret',
},
dropbox: {
key: 'your dropbox key',
secret: 'your dropbox secret',
@ -75,7 +71,7 @@ app.use((err, req, res) => {
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(`Listening on http://0.0.0.0:${3020}`)

View file

@ -9,7 +9,7 @@
"serve": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@tailwindcss/vite": "^4.3.0",
"@uppy/core": "workspace:*",
"@uppy/react": "workspace:*",
"@uppy/remote-sources": "workspace:*",
@ -23,12 +23,14 @@
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/browser": "^3.2.4",
"playwright": "1.57.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser": "^4.1.6",
"@vitest/browser-playwright": "^4.1.6",
"msw": "^2.10.4",
"playwright": "1.60.0",
"typescript": "^5.7.3",
"vite": "^7.1.11",
"vitest": "^3.2.4",
"vitest-browser-react": "^1.0.0"
"vite": "^8.0.14",
"vitest": "^4.1.6",
"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 UppyImageEditor from '@uppy/image-editor'
import {

View file

@ -1,15 +1,25 @@
import { userEvent } from '@vitest/browser/context'
import { describe, expect, test } from 'vitest'
import { setupWorker } from 'msw/browser'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-react'
import { tusHandlers } from '../../shared/tusHandlers.js'
import App from '../src/App'
const worker = setupWorker(...tusHandlers)
beforeAll(async () => {
await worker.start({ onUnhandledRequest: 'bypass' })
})
afterAll(() => {
worker.stop()
})
const createMockFile = (name: string, type: string, size: number = 1024) => {
return new File(['test content'], name, { type })
}
describe('App', () => {
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 grid')).toBeInTheDocument()
@ -23,7 +33,7 @@ describe('App', () => {
})
test('can add and remove files and upload', async () => {
const screen = render(<App />)
const screen = await render(<App />)
const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy',
@ -49,7 +59,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => {
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()
@ -80,7 +90,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => {
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()
@ -111,7 +121,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => {
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()
@ -119,6 +129,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
})
})

View file

@ -6,4 +6,11 @@ import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
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 react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
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: {
browser: {
enabled: true,
provider: 'playwright',
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},

View file

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

View file

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

View file

@ -0,0 +1,39 @@
import { HttpResponse, http } from 'msw'
/**
* MSW handlers that mock the tus resumable upload protocol.
*
* Handles the tus v1 flow used by the example tests:
* 1. POST /files/ create upload, return Location header
* 2. PATCH /files/:id receive chunk, return new Upload-Offset
*
* See https://tus.io/protocols/resumable-upload#protocol
*/
export const TUS_ENDPOINT = 'https://tusd.tusdemo.net/files/'
export const tusHandlers = [
http.post(TUS_ENDPOINT, async ({ request }) => {
const uploadLength = request.headers.get('Upload-Length') || '0'
return new HttpResponse(null, {
status: 201,
headers: {
Location: `${TUS_ENDPOINT}mock-upload-id`,
'Tus-Resumable': '1.0.0',
'Upload-Offset': '0',
'Upload-Length': uploadLength,
},
})
}),
http.patch(`${TUS_ENDPOINT}:id`, async ({ request }) => {
const uploadOffset = request.headers.get('Upload-Offset') || '0'
const body = await request.arrayBuffer()
const newOffset = Number.parseInt(uploadOffset, 10) + body.byteLength
return new HttpResponse(null, {
status: 204,
headers: {
'Tus-Resumable': '1.0.0',
'Upload-Offset': String(newOffset),
},
})
}),
]

View file

@ -26,14 +26,16 @@
"@sveltejs/kit": "^2.49.5",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@vitest/browser": "^3.2.4",
"playwright": "1.57.0",
"@vitest/browser": "^4.1.6",
"@vitest/browser-playwright": "^4.1.6",
"msw": "^2.10.4",
"playwright": "1.60.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.2.5",
"vitest": "^3.2.4",
"vitest-browser-svelte": "^1.0.0"
"vitest": "^4.1.6",
"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">
<input {...getFileInputProps()} class="hidden" />
<button
<button type="button"
{...getButtonProps()}
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
</button>
<button
<button type="button"
onclick={() => openModal('webcam')}
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
</button>
<button
<button type="button"
onclick={() => openModal('screen-capture')}
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
</button>
<button
<button type="button"
onclick={() => openModal('dropbox')}
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 class="mb-4">
<!-- biome-ignore lint/a11y/useAltText: alt is provided via editor.getImageProps() spread -->
<img
class="w-full max-h-[400px] rounded-lg border-2"
{...editor.getImageProps()}
@ -41,31 +42,31 @@ const editor = useImageEditor({ file })
</div>
<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"
{...editor.getRotateButtonProps(-90)}
>
↶ -90°
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getRotateButtonProps(90)}
>
↷ +90°
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getFlipHorizontalButtonProps()}
>
⇆ Flip
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getZoomButtonProps(0.1)}
>
+ Zoom
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getZoomButtonProps(-0.1)}
>
@ -74,25 +75,25 @@ const editor = useImageEditor({ file })
</div>
<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"
{...editor.getCropSquareButtonProps()}
>
1:1
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropLandscapeButtonProps()}
>
16:9
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropPortraitButtonProps()}
>
9:16
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getResetButtonProps()}
>
@ -101,13 +102,13 @@ const editor = useImageEditor({ file })
</div>
<div class="flex gap-4 justify-end">
<button
<button type="button"
class="bg-gray-500 text-white px-4 py-2 rounded-md"
{...editor.getCancelButtonProps({ onClick: close })}
>
Cancel
</button>
<button
<button type="button"
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...editor.getSaveButtonProps({ onClick: close })}
>

View file

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

View file

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

View file

@ -14,9 +14,9 @@ let hideUploadButton = false
</script>
<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} />
<button onclick={() => (hideUploadButton = !hideUploadButton)}>Toggle statusbar</button>
<button type="button" onclick={() => (hideUploadButton = !hideUploadButton)}>Toggle statusbar</button>
<div id="statusbar-container">
<StatusBar props={{ hideUploadButton }} uppy={uppy} />
</div>

View file

@ -1,16 +1,26 @@
import { userEvent } from '@vitest/browser/context'
import { describe, expect, test } from 'vitest'
import { setupWorker } from 'msw/browser'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-svelte'
import { tusHandlers } from '../../shared/tusHandlers.js'
import PropsReactivity from '../src/components/test/props-reactivity.svelte'
import App from '../src/routes/+page.svelte'
const worker = setupWorker(...tusHandlers)
beforeAll(async () => {
await worker.start({ onUnhandledRequest: 'error' })
})
afterAll(() => {
worker.stop()
})
const createMockFile = (name: string, type: string, size: number = 1024) => {
return new File(['test content'], name, { type })
}
describe('App', () => {
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 grid')).toBeInTheDocument()
@ -24,7 +34,7 @@ describe('App', () => {
})
test('can add and remove files and upload', async () => {
const screen = render(App)
const screen = await render(App)
const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy',
@ -50,7 +60,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => {
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', exact: true })
@ -83,7 +93,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => {
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()
@ -114,7 +124,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => {
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()
@ -122,12 +132,11 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
})
})
test('Dashboard reacts to prop changes', async () => {
const screen = render(PropsReactivity)
const screen = await render(PropsReactivity)
const toggleButton = screen.getByText('Toggle dashboard')
const dashboard = screen.container.querySelector('.uppy-Dashboard')
@ -138,7 +147,7 @@ test('Dashboard 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')
expect(

View file

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

View file

@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<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
function and receive a listen to an event with upload results ✌️
</p>
<button onclick="openModal()">Open</button>
<button type="button" onclick="openModal()">Open</button>
<h2>uppy.upload()</h2>
<p>An &lt;input type=file&gt; backed by <code>uppy.upload()</code>:</p>

View file

@ -50,10 +50,12 @@ const formUppy = new Uppy({
})
.use(Transloadit, {
waitForEncoding: true,
assemblyOptions: {
params: {
auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
},
})
formUppy.on('error', (err) => {
@ -100,10 +102,12 @@ const formUppyWithDashboard = new Uppy({
})
.use(Transloadit, {
waitForEncoding: true,
assemblyOptions: {
params: {
auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
},
})
window.formUppyWithDashboard = formUppyWithDashboard
@ -129,10 +133,12 @@ const dashboard = new Uppy({
.use(ImageEditor, { target: Dashboard })
.use(Transloadit, {
waitForEncoding: true,
assemblyOptions: {
params: {
auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
},
})
window.dashboard = dashboard
@ -151,10 +157,12 @@ const dashboardModal = new Uppy({
.use(ImageEditor, { target: Dashboard })
.use(Transloadit, {
waitForEncoding: true,
assemblyOptions: {
params: {
auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
},
})
dashboardModal.on('complete', ({ transloadit, successful, failed }) => {
@ -182,10 +190,12 @@ const uppyWithoutUI = new Uppy({
},
}).use(Transloadit, {
waitForEncoding: true,
assemblyOptions: {
params: {
auth: { key: TRANSLOADIT_KEY },
template_id: TEMPLATE_ID,
},
},
})
window.doUpload = (event) => {

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@
>
<div class="flex items-center justify-center gap-4">
<input v-bind="getFileInputProps()" class="hidden" />
<button
<button type="button"
v-bind="getButtonProps()"
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
</button>
<button
<button type="button"
@click="() => props.openModal('webcam')"
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
</button>
<button
<button type="button"
@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"
>
@ -37,7 +37,7 @@
Screen Capture
</button>
<button
<button type="button"
@click="() => props.openModal('dropbox')"
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 class="mb-4">
<!-- biome-ignore lint/a11y/useAltText: alt is provided via editor.getImageProps() spread -->
<img
class="w-full max-h-[400px] rounded-lg border-2"
v-bind="editor.getImageProps()"
@ -29,31 +30,31 @@
</div>
<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"
v-bind="editor.getRotateButtonProps(-90)"
>
-90°
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getRotateButtonProps(90)"
>
+90°
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getFlipHorizontalButtonProps()"
>
Flip
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getZoomButtonProps(0.1)"
>
+ Zoom
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getZoomButtonProps(-0.1)"
>
@ -62,25 +63,25 @@
</div>
<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"
v-bind="editor.getCropSquareButtonProps()"
>
1:1
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropLandscapeButtonProps()"
>
16:9
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropPortraitButtonProps()"
>
9:16
</button>
<button
<button type="button"
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getResetButtonProps()"
>
@ -89,13 +90,13 @@
</div>
<div class="flex gap-4 justify-end">
<button
<button type="button"
class="bg-gray-500 text-white px-4 py-2 rounded-md"
v-bind="editor.getCancelButtonProps({ onClick: close })"
>
Cancel
</button>
<button
<button type="button"
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 })"
>

View file

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

View file

@ -1,15 +1,25 @@
import { userEvent } from '@vitest/browser/context'
import { describe, expect, test } from 'vitest'
import { setupWorker } from 'msw/browser'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-vue'
import { tusHandlers } from '../../shared/tusHandlers.js'
import App from '../src/App.vue'
const worker = setupWorker(...tusHandlers)
beforeAll(async () => {
await worker.start({ onUnhandledRequest: 'error' })
})
afterAll(() => {
worker.stop()
})
const createMockFile = (name: string, type: string, size: number = 1024) => {
return new File(['test content'], name, { type })
}
describe('App', () => {
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 grid')).toBeInTheDocument()
@ -23,7 +33,7 @@ describe('App', () => {
})
test('can add and remove files and upload', async () => {
const screen = render(App)
const screen = await render(App)
const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy',
@ -49,7 +59,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => {
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', exact: true })
@ -82,7 +92,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => {
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()
@ -113,7 +123,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => {
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()
@ -121,6 +131,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
"name": "@uppy-dev/build",
"version": "0.0.0",
"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",
"workspaces": [
"examples/*",
@ -29,7 +29,7 @@
"release": "changeset publish"
},
"devDependencies": {
"@biomejs/biome": "2.0.5",
"@biomejs/biome": "2.4.15",
"@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",
"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-dynamic": "^19.2.17",
"@angular/router": "^19.2.17",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"rxjs": "~7.8.2",
"tslib": "^2.8.1",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@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/compiler-cli": "^19.2.17",
"@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",
"karma": "~6.4.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 .."
},
"dependencies": {
"tslib": "^2.0.0"
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ export default defineConfig({
exclude: ['**/minio.test.ts'],
environment: 'jsdom',
},
}
},
{
test: {
name: 's3-browser',

View file

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

View file

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

View file

@ -5,8 +5,11 @@ import type {
UnknownProviderPlugin,
Uppy,
} from '@uppy/core'
import type { CompanionClientProvider, RequestOptions } from '@uppy/utils'
import { isOriginAllowed } from './getAllowedHosts.js'
import {
type CompanionClientProvider,
getSocketHost,
type RequestOptions,
} from '@uppy/utils'
import type { CompanionPluginOptions } from './index.js'
import RequestClient, { authErrorStatusCode } from './RequestClient.js'
@ -135,22 +138,27 @@ export default class Provider<M extends Meta, B extends Body>
authUrl({
authFormData,
query,
authCallbackToken,
}: {
authFormData: unknown
query: Record<string, string>
authCallbackToken?: string | undefined
}): string {
const params = new URLSearchParams({
const searchParams = new URLSearchParams({
...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() })),
...this.authQuery({ authFormData }),
})
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({
@ -181,67 +189,62 @@ export default class Provider<M extends Meta, B extends Body>
signal: AbortSignal
}): Promise<void> {
await this.ensurePreAuth()
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')
let interval: number | undefined
let handleMessage: ((e: MessageEvent<any>) => void) | undefined
let webSocket: WebSocket | undefined
try {
return await new Promise((resolve, reject) => {
handleMessage = (e: MessageEvent<any>) => {
if (e.source !== authWindow) {
let jsonData = ''
const host = getSocketHost(this.opts.companionUrl)
// Note that this promise is not guaranteed to settle in all cases
const token = await new Promise<string>((resolve, reject) => {
webSocket = new WebSocket(
`${host}/api2/auth-callback/token/${authCallbackToken}`,
)
webSocket.addEventListener('close', () => {
reject(new Error('Socket closed'))
})
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 {
// TODO improve our uppy logger so that it can take an arbitrary number of arguments,
// 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.
jsonData = JSON.stringify(e.data)
} catch (_err) {
// in case JSON.stringify fails (ignored)
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(
`ignoring event from unknown source ${jsonData}`,
'warning',
} catch (err) {
reject(err)
}
})
signal.addEventListener('abort', () =>
reject(new Error('Authentication was aborted')),
)
return
}
const { companionAllowedHosts } = this.#getPlugin().opts
if (!isOriginAllowed(e.origin, companionAllowedHosts)) {
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
if (authWindow) {
@ -251,15 +254,21 @@ export default class Provider<M extends Meta, B extends Body>
}
}, 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 {
// cleanup:
authWindow?.close()
window.clearInterval(interval)
if (handleMessage) window.removeEventListener('message', handleMessage)
// if we don't setTimeout, the window doesn't really close (I don't know why).
setTimeout(() => authWindow?.close(), 1)
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:
* 1. #requestSocketToken which starts the download/upload in companion and returns a unique token for the upload.
* Then companion will halt the upload until:
* 2. #awaitRemoteFileUpload is called, which will open/ensure a websocket connection towards companion, with the
* previously generated token provided. It returns a promise that will resolve/reject once the file has finished
* uploading or is otherwise done (failed, canceled)
* Remote uploading uses a single queue admission per attempt:
* 1. Acquire one queue slot.
* 2. Reuse an existing serverToken or request a new one from Companion.
* 3. Open/maintain the websocket and wait for Companion to finish.
*
* This prevents socket tokens from being created long before their websocket
* session is admitted by the queue.
*/
async uploadRemoteFile(
file: RemoteUppyFile<M, B>,
@ -248,73 +249,47 @@ export default class RequestClient<M extends Meta, B extends Body> {
): Promise<void> {
try {
const { signal, getQueue } = options || {}
const queue = getQueue()
return await pRetry(
async () => {
// if we already have a serverToken, assume that we are resuming the existing server upload id
const existingServerToken = this.uppy.getFile(file.id)?.serverToken
if (existingServerToken != null) {
this.uppy.log(
`Connecting to exiting websocket ${existingServerToken}`,
)
return this.#awaitRemoteFileUpload({
file,
queue: getQueue(),
signal,
})
}
const queueRemoteUploadAttempt = queue.wrapPromiseFunction(
async () => {
const currentFile = this.uppy.getFile(file.id) as
| RemoteUppyFile<M, B>
| undefined
if (currentFile == null) return undefined
const queueRequestSocketToken = getQueue().wrapPromiseFunction(
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)
let serverToken = currentFile.serverToken
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
}
},
{ priority: -1 },
)
const serverToken = await queueRequestSocketToken({
file,
if (serverToken != null) {
this.uppy.log(`Connecting to exiting websocket ${serverToken}`)
} else {
serverToken = await this.#requestSocketTokenWithRetryStrategy({
file: currentFile,
postBody: reqBody,
signal,
}).abortOn(signal)
})
if (!this.uppy.getFile(file.id)) return undefined // has file since been removed?
if (!this.uppy.getFile(file.id)) return undefined
this.uppy.setFileState(file.id, { serverToken })
}
const latestFile = this.uppy.getFile(file.id) as
| RemoteUppyFile<M, B>
| undefined
if (latestFile == null) return undefined
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(),
file: latestFile,
signal,
})
},
)
return queueRemoteUploadAttempt().abortOn(signal)
},
{
retries: retryCount,
signal,
@ -360,6 +335,39 @@ export default class RequestClient<M extends Meta, B extends Body> {
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
* 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({
file,
queue,
signal,
}: {
file: RemoteUppyFile<M, B>
queue: any
signal: AbortSignal
}): Promise<void> {
let removeEventHandlers: () => void
@ -430,20 +436,14 @@ export default class RequestClient<M extends Meta, B extends Body> {
function resetActivityTimeout() {
clearTimeout(activityTimeout)
if (isPaused) return
activityTimeout = setTimeout(
() =>
activityTimeout = setTimeout(() => {
onFatalError(
new Error(
'Timeout waiting for message from Companion socket',
),
),
socketActivityTimeoutMs,
new Error('Timeout waiting for message from Companion socket'),
)
}, socketActivityTimeoutMs)
}
try {
await queue
.wrapPromiseFunction(async () => {
const reconnectWebsocket = async () =>
new Promise((_, rejectSocket) => {
socket = new WebSocket(`${host}/api/${token}`)
@ -499,9 +499,7 @@ export default class RequestClient<M extends Meta, B extends Body> {
{
uploadURL: payload.url,
status: payload.response?.status ?? 200,
body: text
? (JSON.parse(text) as B)
: undefined,
body: text ? (JSON.parse(text) as B) : undefined,
},
)
socketAbortController?.abort?.()
@ -532,12 +530,9 @@ export default class RequestClient<M extends Meta, B extends Body> {
socket = undefined
}
socketAbortController.signal.addEventListener(
'abort',
() => {
socketAbortController.signal.addEventListener('abort', () => {
closeSocket()
},
)
})
})
await pRetry(reconnectWebsocket, {
@ -548,8 +543,6 @@ export default class RequestClient<M extends Meta, B extends Body> {
this.uppy.log(`Retrying websocket ${file.id}`)
},
})
})()
.abortOn(socketAbortController.signal)
} catch (err) {
if (socketAbortController.signal.aborted) return
onFatalError(err)

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import getAllowedHosts, { isOriginAllowed } from './getAllowedHosts.js'
import getAllowedHosts from './getAllowedHosts.js'
describe('getAllowedHosts', () => {
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)
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
# Transpiled
./lib/
./dist/
infra/kube/companion/uppy-env.yaml
scripts/.tl-deploy-hosts-danger.txt

View file

@ -1,5 +1,12 @@
# @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
### Patch Changes

View file

@ -38,8 +38,6 @@ data:
COMPANION_BOX_SECRET: 'YOUR BOX SECRET'
COMPANION_GOOGLE_KEY: 'YOUR GOOGLE KEY'
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_SECRET: 'YOUR AWS SECRET'
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.
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
you want resumable) or your existing Apache/Nginx server (if you dont).
[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)
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

View file

@ -3,7 +3,14 @@ class Gauge {
}
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:
if (req.url === '/metrics') {
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_FILE=
COMPANION_INSTAGRAM_KEY=
COMPANION_INSTAGRAM_SECRET=
COMPANION_INSTAGRAM_SECRET_FILE=
COMPANION_FACEBOOK_KEY=
COMPANION_FACEBOOK_SECRET=
COMPANION_FACEBOOK_SECRET_FILE=

View file

@ -9,5 +9,5 @@
"debug": true,
"watch": ["/app/", "/src/"],
"ext": "js dust html ejs css scss rb json htpasswd",
"exec": "node /app/lib/standalone/start-server.js"
"exec": "node /app/dist/standalone/start-server.js"
}

View file

@ -1,8 +1,8 @@
{
"name": "@uppy/companion",
"version": "6.2.1",
"version": "6.2.2",
"description": "OAuth helper and remote fetcher for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:",
"types": "lib/companion.d.ts",
"types": "dist/companion.d.ts",
"author": "Transloadit.com",
"license": "MIT",
"sideEffects": false,
@ -13,8 +13,8 @@
"url": "git+https://github.com/transloadit/uppy.git"
},
"exports": {
".": "./lib/companion.js",
"./standalone": "./lib/standalone/index.js",
".": "./dist/companion.js",
"./standalone": "./dist/standalone/index.js",
"./package.json": "./package.json"
},
"keywords": [
@ -32,84 +32,92 @@
"express",
"realtime"
],
"bin": "./bin/companion",
"bin": "./dist/bin/companion.js",
"dependencies": {
"@aws-sdk/client-s3": "^3.338.0",
"@aws-sdk/client-sts": "^3.338.0",
"@aws-sdk/lib-storage": "^3.338.0",
"@aws-sdk/s3-presigned-post": "^3.338.0",
"@aws-sdk/s3-request-presigner": "^3.338.0",
"body-parser": "1.20.4",
"@aws-sdk/client-s3": "3.1047.0",
"@aws-sdk/client-sts": "3.1047.0",
"@aws-sdk/lib-storage": "3.1047.0",
"@aws-sdk/s3-presigned-post": "3.1047.0",
"@aws-sdk/s3-request-presigner": "3.1047.0",
"body-parser": "2.2.2",
"common-tags": "1.8.2",
"connect-redis": "7.1.1",
"content-disposition": "^0.5.4",
"content-disposition": "2.0.0",
"cookie-parser": "1.4.7",
"cors": "^2.8.5",
"escape-goat": "3.0.0",
"escape-string-regexp": "4.0.0",
"express": "4.21.2",
"cors": "2.8.6",
"escape-goat": "4.0.0",
"escape-string-regexp": "5.0.0",
"express": "^5.2.1",
"express-interceptor": "1.2.0",
"express-prom-bundle": "7",
"express-session": "1.18.1",
"express-prom-bundle": "^8.0.0",
"express-session": "1.19.0",
"fast-safe-stringify": "^2.1.1",
"formdata-node": "^6.0.3",
"got": "^13.0.0",
"grant": "^5.4.24",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"ipaddr.js": "^2.0.1",
"jsonwebtoken": "9.0.2",
"lodash": "^4.17.23",
"mime-types": "2.1.35",
"moment": "^2.29.2",
"moment-timezone": "^0.5.31",
"morgan": "1.10.0",
"helmet": "8.1.0",
"ioredis": "5.10.1",
"ipaddr.js": "2.4.0",
"jsonwebtoken": "9.0.3",
"lodash": "4.18.1",
"mime-types": "3.0.2",
"moment": "2.30.1",
"moment-timezone": "0.6.2",
"morgan": "1.10.1",
"ms": "2.1.3",
"node-schedule": "2.1.1",
"p-map": "^7.0.3",
"prom-client": "15.1.2",
"serialize-error": "^11.0.0",
"serialize-javascript": "^6.0.0",
"supports-color": "8.x",
"tus-js-client": "^4.1.0",
"validator": "^13.15.22",
"webdav": "^5.8.0",
"ws": "8.17.1"
"p-map": "7.0.4",
"prom-client": "15.1.3",
"serialize-error": "13.0.1",
"serialize-javascript": "7.0.5",
"supports-color": "10.2.2",
"tus-js-client": "4.3.1",
"validator": "13.15.35",
"webdav": "5.10.0",
"ws": "8.20.1",
"zod": "4.4.3"
},
"devDependencies": {
"@types/compression": "1.7.0",
"@types/cookie-parser": "1.4.2",
"@types/common-tags": "1.8.4",
"@types/compression": "1.8.1",
"@types/content-disposition": "0.5.9",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.6",
"@types/eslint": "^8.2.0",
"@types/express-session": "1.17.3",
"@types/http-proxy": "^1",
"@types/jsonwebtoken": "8.3.7",
"@types/lodash": "4.14.191",
"@types/eslint": "9.6.1",
"@types/express-session": "1.19.0",
"@types/http-proxy": "1.17.17",
"@types/jsonwebtoken": "9.0.10",
"@types/lodash": "4.17.24",
"@types/mime-types": "3.0.1",
"@types/morgan": "1.7.37",
"@types/ms": "0.7.31",
"@types/node": "^20.19.0",
"@types/request": "2.48.8",
"@types/webpack": "^5.28.0",
"@types/ws": "8.5.3",
"execa": "^9.6.0",
"http-proxy": "^1.18.1",
"nock": "^13.1.3",
"supertest": "6.2.4",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
"@types/ms": "2.1.0",
"@types/node": "20.19.41",
"@types/node-schedule": "2.1.8",
"@types/request": "2.48.13",
"@types/serialize-javascript": "5.0.4",
"@types/supertest": "7.2.0",
"@types/validator": "13.15.10",
"@types/webpack": "5.28.5",
"@types/ws": "8.18.1",
"execa": "9.6.1",
"http-proxy": "1.18.1",
"nock": "^14.0.15",
"supertest": "7.2.2",
"typescript": "6.0.3",
"vitest": "4.1.6"
},
"files": [
"bin/",
"lib/",
"dist/",
"src/",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc --build tsconfig.build.json",
"deploy": "kubectl apply -f infra/kube/companion-kube.yml",
"start": "node ./lib/standalone/start-server.js",
"start": "node ./dist/standalone/start-server.js",
"start:dev": "bash start-dev",
"typecheck": "tsc --build",
"typecheck": "tsc",
"check": "yarn typecheck && yarn test",
"test": "vitest run --silent='passed-only'"
},
"engines": {

View file

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

View file

@ -11,6 +11,10 @@ import {
validateConfig,
} from './config/companion.js'
import grantConfigFn from './config/grant.js'
import type {
CompanionInitOptions,
CredentialsFetchResponse,
} from './schemas/index.js'
import googlePicker from './server/controllers/googlePicker.js'
import * as controllers from './server/controllers/index.js'
import s3 from './server/controllers/s3.js'
@ -28,17 +32,22 @@ import {
ProviderUserError,
} from './server/provider/error.js'
import * as providerManager from './server/provider/index.js'
import type Provider from './server/provider/Provider.js'
import { isOAuthProvider } from './server/provider/Provider.js'
import * as redis from './server/redis.js'
import socket from './server/socket.js'
import type { CompanionRuntimeOptions } from './types/companion-options.js'
export { socket }
const grantConfig = grantConfigFn()
export function setLoggerProcessName({ loggerProcessName }) {
if (loggerProcessName != null) logger.setProcessName(loggerProcessName)
export function setLoggerProcessName({
loggerProcessName,
}: Pick<CompanionRuntimeOptions, 'loggerProcessName'>) {
if (loggerProcessName != null) {
logger.setProcessName(loggerProcessName)
}
}
// intercepts grantJS' default response error when something goes
@ -83,11 +92,8 @@ export const errors = {
/**
* Entry point into initializing the Companion app.
*
* @param {object} optionsArg
* @returns {{ app: import('express').Express, emitter: any }}}
*/
export function app(optionsArg = {}) {
export function app(optionsArg: CompanionInitOptions) {
setLoggerProcessName(optionsArg)
validateConfig(optionsArg)
@ -96,13 +102,17 @@ export function app(optionsArg = {}) {
const providers = providerManager.getDefaultProviders()
const { customProviders } = options
const customProviders = options.customProviders
if (customProviders) {
providerManager.addCustomProviders(customProviders, providers, grantConfig)
providerManager.addCustomProviders(
customProviders,
providers as Record<string, typeof Provider>,
grantConfig,
)
}
const getOauthProvider = (providerName) =>
providers[providerName]?.oauthProvider
const getOauthProvider = (providerName: string) =>
providers[providerName as keyof typeof providers]?.oauthProvider
providerManager.addProviderOptions(options, grantConfig, getOauthProvider)
@ -115,21 +125,29 @@ export function app(optionsArg = {}) {
const app = express()
// Needed for e.g. s3 `/params` endpoint metadata, like `metadata[key]=value` to be parsed into a metadata object
app.set('query parser', 'extended')
if (options.metrics) {
app.use(middlewares.metrics({ path: options.server.path }))
}
app.get('/health', (_req, res) => res.end())
app.use(cookieParser()) // server tokens are added to cookies
app.use(interceptGrantErrorResponse)
// override provider credentials at request time
// Making `POST` request to the `/connect/:provider/:override?` route requires a form body parser middleware:
// Making `POST` request to the `/connect/:provider{/:override}` route requires a form body parser middleware:
// See https://github.com/simov/grant#dynamic-http
app.use(
'/connect/:oauthProvider/:override?',
'/connect/:oauthProvider{/:override}',
express.urlencoded({ extended: false }),
getCredentialsOverrideMiddleware(providers, options),
getCredentialsOverrideMiddleware(
providers as Record<string, typeof Provider>,
options,
),
)
app.use(grant.default.express(grantConfig))
@ -144,7 +162,7 @@ export function app(optionsArg = {}) {
app.use(middlewares.cors(options))
// add uppy options to the request object so it can be accessed by subsequent handlers.
app.use('*', middlewares.getCompanionMiddleware(options))
app.use(middlewares.getCompanionMiddleware(options))
app.use('/s3', s3(options.s3))
if (options.enableUrlEndpoint) app.use('/url', url())
if (options.enableGooglePickerEndpoint)
@ -217,7 +235,7 @@ export function app(optionsArg = {}) {
)
app.get(
'/:providerName/list/:id?',
'/:providerName/list{/:id}',
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
controllers.list,
@ -263,19 +281,28 @@ export function app(optionsArg = {}) {
// Used for testing dynamic credentials only, normally this would run on a separate server.
if (options.testDynamicOauthCredentials) {
logger.info('Dynamic credentials test endpoint enabled')
app.post('/:providerName/test-dynamic-oauth-credentials', (req, res) => {
if (req.query.secret !== options.testDynamicOauthCredentialsSecret)
const providedSecret = req.query['secret']
console.log('Received request for dynamic credentials test endpoint', {
providedSecret,
})
if (
typeof providedSecret !== 'string' ||
providedSecret !== options.testDynamicOauthCredentialsSecret
) {
throw new Error('Invalid secret')
}
const { providerName } = req.params
// for simplicity, we just return the normal credentials for the provider, but in a real-world scenario,
// we would query based on parameters
const { key, secret } = options.providerOptions[providerName] ?? {
__proto__: null,
}
const { key, secret } = options.providerOptions[providerName]!
function getTransloaditGateway() {
const oauthProvider = getOauthProvider(providerName)
if (!isOAuthProvider(oauthProvider)) return undefined
if (!isOAuthProvider(oauthProvider))
throw new Error('Not an OAuth provider')
return getURLBuilder(options)('', true)
}
@ -285,7 +312,7 @@ export function app(optionsArg = {}) {
secret,
transloadit_gateway: getTransloaditGateway(),
origins: ['http://localhost:5173'],
},
} satisfies CredentialsFetchResponse,
}
logger.info(
@ -299,7 +326,10 @@ export function app(optionsArg = {}) {
app.param(
'providerName',
providerManager.getProviderMiddleware(providers, grantConfig),
providerManager.getProviderMiddleware(
providers as Record<string, typeof Provider>,
grantConfig,
),
)
if (app.get('env') !== 'test') {

View file

@ -1,164 +0,0 @@
import fs from 'node:fs'
import validator from 'validator'
import { defaultGetKey } from '../server/helpers/utils.js'
import logger from '../server/logger.js'
export const defaultOptions = {
server: {
protocol: 'http',
path: '',
},
providerOptions: {},
s3: {
endpoint: 'https://{service}.{region}.amazonaws.com',
conditions: [],
useAccelerateEndpoint: false,
getKey: defaultGetKey,
expires: 800, // seconds
},
enableUrlEndpoint: false,
enableGooglePickerEndpoint: false,
allowLocalUrls: false,
periodicPingUrls: [],
streamingUpload: true,
clientSocketConnectTimeout: 60000,
metrics: true,
}
/**
* @param {object} companionOptions
*/
export function getMaskableSecrets(companionOptions) {
const secrets = []
const { providerOptions, customProviders, s3 } = companionOptions
Object.keys(providerOptions).forEach((provider) => {
if (providerOptions[provider].secret) {
secrets.push(providerOptions[provider].secret)
}
})
if (customProviders) {
Object.keys(customProviders).forEach((provider) => {
if (customProviders[provider].config?.secret) {
secrets.push(customProviders[provider].config.secret)
}
})
}
if (s3?.secret) {
secrets.push(s3.secret)
}
return secrets
}
/**
* validates that the mandatory companion options are set.
* If it is invalid, it will console an error of unset options and exits the process.
* If it is valid, nothing happens.
*
* @param {object} companionOptions
*/
export const validateConfig = (companionOptions) => {
const mandatoryOptions = ['secret', 'filePath', 'server.host']
/** @type {string[]} */
const unspecified = []
mandatoryOptions.forEach((i) => {
const value = i
.split('.')
.reduce((prev, curr) => (prev ? prev[curr] : undefined), companionOptions)
if (!value) unspecified.push(`"${i}"`)
})
// vaidate that all required config is specified
if (unspecified.length) {
const messagePrefix =
'Please specify the following options to use companion:'
throw new Error(`${messagePrefix}\n${unspecified.join(',\n')}`)
}
// validate that specified filePath is writeable/readable.
try {
// @ts-ignore
fs.accessSync(
`${companionOptions.filePath}`,
fs.constants.R_OK | fs.constants.W_OK,
)
} catch (_err) {
throw new Error(
`No access to "${companionOptions.filePath}". Please ensure the directory exists and with read/write permissions.`,
)
}
const { providerOptions, periodicPingUrls, server } = companionOptions
if (server?.path) {
// see https://github.com/transloadit/uppy/issues/4271
// todo fix the code so we can allow `/`
if (server.path === '/')
throw new Error(
"If you want to use '/' as server.path, leave the 'path' variable unset",
)
}
if (providerOptions) {
const deprecatedOptions = {
microsoft: 'providerOptions.onedrive',
google: 'providerOptions.drive',
s3: 's3',
}
Object.keys(deprecatedOptions).forEach((deprecated) => {
if (Object.hasOwn(providerOptions, deprecated)) {
throw new Error(
`The Provider option "providerOptions.${deprecated}" is no longer supported. Please use the option "${deprecatedOptions[deprecated]}" instead.`,
)
}
})
}
if (
companionOptions.uploadUrls == null ||
companionOptions.uploadUrls.length === 0
) {
if (process.env.NODE_ENV === 'production')
throw new Error('uploadUrls is required')
logger.error(
'Running without uploadUrls is a security risk and Companion will refuse to start up when running in production (NODE_ENV=production)',
'startup.uploadUrls',
)
}
if (companionOptions.corsOrigins == null) {
throw new TypeError(
'Option corsOrigins is required. To disable security, pass true',
)
}
if (companionOptions.corsOrigins === '*') {
throw new TypeError(
'Option corsOrigins cannot be "*". To disable security, pass true',
)
}
if (
periodicPingUrls != null &&
(!Array.isArray(periodicPingUrls) ||
periodicPingUrls.some(
(url2) =>
!validator.isURL(url2, {
protocols: ['http', 'https'],
require_protocol: true,
require_tld: false,
}),
))
) {
throw new TypeError('Invalid periodicPingUrls')
}
if (companionOptions.maxFilenameLength <= 0) {
throw new TypeError('Option maxFilenameLength must be greater than 0')
}
}

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