From 1c1062a9cc0d18af862a2c66ba921b6a1d02e650 Mon Sep 17 00:00:00 2001 From: Evgenia Karunus Date: Tue, 12 Mar 2024 12:37:15 +0400 Subject: [PATCH 01/12] Fix breadcrumbs (#4986) ProviderView.tsx - fix onedrive breadcrumbs --- packages/@uppy/core/src/Uppy.ts | 2 +- packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index 9a6d70c084c..cea4386535e 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -64,7 +64,7 @@ export type UnknownPlugin< export type UnknownProviderPluginState = { authenticated: boolean | undefined breadcrumbs: { - requestPath: string + requestPath?: string name?: string id?: string }[] diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx index 72027716d3e..5c6597916bc 100644 --- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx @@ -233,7 +233,7 @@ export default class ProviderView extends View< if (index !== -1) { // means we navigated back to a known directory (already in the stack), so cut the stack off there breadcrumbs = breadcrumbs.slice(0, index + 1) - } else if (requestPath) { + } else { // we have navigated into a new (unknown) folder, add it to the stack breadcrumbs = [...breadcrumbs, { requestPath, name }] } From 70ca9e6f01693f97b43559dcb71cb90892eed74a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 13 Mar 2024 11:26:22 +0200 Subject: [PATCH 02/12] @uppy/utils: fix `AbortablePromise` type (#4988) --- packages/@uppy/utils/src/RateLimitedQueue.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/@uppy/utils/src/RateLimitedQueue.ts b/packages/@uppy/utils/src/RateLimitedQueue.ts index 1c769e9a197..2948263c43d 100644 --- a/packages/@uppy/utils/src/RateLimitedQueue.ts +++ b/packages/@uppy/utils/src/RateLimitedQueue.ts @@ -30,11 +30,15 @@ type QueueOptions = { priority?: number } -interface AbortablePromise extends Promise { +export interface AbortablePromise extends Promise { abort(cause?: unknown): void - abortOn: typeof abortOn + abortOn: (...args: Parameters) => AbortablePromise } +export type WrapPromiseFunctionType any> = ( + ...args: Parameters +) => AbortablePromise>> + export class RateLimitedQueue { #activeRequests = 0 @@ -223,7 +227,7 @@ export class RateLimitedQueue { outerPromise.abort = (cause) => { queuedRequest.abort(cause) } - outerPromise.abortOn = abortOn + outerPromise.abortOn = abortOn as any return outerPromise } From 53e6046a1e532049b8dae7092c018de0aa8e3167 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 14 Mar 2024 14:57:25 +0200 Subject: [PATCH 03/12] @uppy/status-bar: fix `recoveredState` type (#4996) Co-authored-by: Murderlon --- packages/@uppy/status-bar/src/Components.tsx | 2 +- packages/@uppy/status-bar/src/StatusBarUI.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@uppy/status-bar/src/Components.tsx b/packages/@uppy/status-bar/src/Components.tsx index ee5cd833320..11bc73eb282 100644 --- a/packages/@uppy/status-bar/src/Components.tsx +++ b/packages/@uppy/status-bar/src/Components.tsx @@ -15,7 +15,7 @@ const renderDot = (): string => ` ${DOT} ` interface UploadBtnProps { newFiles: number isUploadStarted: boolean - recoveredState: null | State + recoveredState: State['recoveredState'] i18n: I18n uploadState: string isSomeGhost: boolean diff --git a/packages/@uppy/status-bar/src/StatusBarUI.tsx b/packages/@uppy/status-bar/src/StatusBarUI.tsx index b81ff193f72..df3ec2d19b7 100644 --- a/packages/@uppy/status-bar/src/StatusBarUI.tsx +++ b/packages/@uppy/status-bar/src/StatusBarUI.tsx @@ -38,7 +38,7 @@ export interface StatusBarUIProps { hidePauseResumeButton?: boolean hideCancelButton?: boolean hideRetryButton?: boolean - recoveredState: null | State + recoveredState: State['recoveredState'] uploadState: (typeof statusBarStates)[keyof typeof statusBarStates] totalProgress: number files: Record> From da623ffa7f85632fabd7388f112314211b3c5de2 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 14 Mar 2024 19:12:48 +0200 Subject: [PATCH 04/12] @uppy/utils: fix `findAllDOMElements` type (#4997) --- packages/@uppy/utils/src/findAllDOMElements.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/@uppy/utils/src/findAllDOMElements.ts b/packages/@uppy/utils/src/findAllDOMElements.ts index 1df2feb3fc3..2701bf63b95 100644 --- a/packages/@uppy/utils/src/findAllDOMElements.ts +++ b/packages/@uppy/utils/src/findAllDOMElements.ts @@ -1,11 +1,16 @@ import isDOMElement from './isDOMElement.ts' +function findAllDOMElements( + element: T, + context?: ParentNode, +): T extends Element ? [T] +: T extends Node | string ? Element[] | null +: null + /** * Find one or more DOM elements. */ -export default function findAllDOMElements( - element: string | Node, -): Node[] | null { +function findAllDOMElements(element: unknown): Node[] | null { if (typeof element === 'string') { const elements = document.querySelectorAll(element) return elements.length === 0 ? null : Array.from(elements) @@ -17,3 +22,5 @@ export default function findAllDOMElements( return null } + +export default findAllDOMElements From ac0f07a7504f194e3cde74bd918160827cd98b06 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 14 Mar 2024 19:13:17 +0200 Subject: [PATCH 05/12] @uppy/core: various type fixes (#4995) * @uppy/core: fix `addTarget` types * @uppy/core: export `UploadResult` type * @uppy/core: expose `clearUploadedFiles` for Dashboard * @uppy/golden-retriever changes --------- Co-authored-by: Murderlon --- packages/@uppy/core/src/BasePlugin.ts | 4 ++-- packages/@uppy/core/src/UIPlugin.ts | 4 ++-- packages/@uppy/core/src/Uppy.ts | 13 ++++++++----- packages/@uppy/core/src/index.ts | 3 ++- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/@uppy/core/src/BasePlugin.ts b/packages/@uppy/core/src/BasePlugin.ts index b77de1e4590..53bb06df8b8 100644 --- a/packages/@uppy/core/src/BasePlugin.ts +++ b/packages/@uppy/core/src/BasePlugin.ts @@ -16,7 +16,7 @@ import type { OptionalPluralizeLocale, } from '@uppy/utils/lib/Translator' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { State, Uppy } from './Uppy' +import type { State, UnknownPlugin, Uppy } from './Uppy' export type PluginOpts = { locale?: Locale @@ -111,7 +111,7 @@ export default class BasePlugin< */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - addTarget(plugin: unknown): HTMLElement { + addTarget(plugin: UnknownPlugin): HTMLElement | null { throw new Error( "Extend the addTarget method to add your plugin to another plugin's target", ) diff --git a/packages/@uppy/core/src/UIPlugin.ts b/packages/@uppy/core/src/UIPlugin.ts index e44072151b0..e2d61291cbb 100644 --- a/packages/@uppy/core/src/UIPlugin.ts +++ b/packages/@uppy/core/src/UIPlugin.ts @@ -137,7 +137,7 @@ class UIPlugin< this.onMount() - return this.el + return this.el! } const targetPlugin = this.getTargetPlugin(target) @@ -148,7 +148,7 @@ class UIPlugin< this.el = targetPlugin.addTarget(plugin) this.onMount() - return this.el + return this.el! } this.uppy.log(`Not installing ${callerPluginName}`) diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index cea4386535e..ec338d5812c 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -132,7 +132,7 @@ export type UnknownSearchProviderPlugin< provider: CompanionClientSearchProvider } -interface UploadResult { +export interface UploadResult { successful?: UppyFile[] failed?: UppyFile[] uploadID?: string @@ -156,10 +156,12 @@ export interface State uploadProgress: boolean individualCancellation: boolean resumableUploads: boolean + isMobileDevice?: boolean + darkMode?: boolean } currentUploads: Record> allowNewUpload: boolean - recoveredState: null | State + recoveredState: null | Required, 'files' | 'currentUploads'>> error: string | null files: { [key: string]: UppyFile @@ -317,8 +319,9 @@ export interface _UppyEventMap { 'preprocess-progress': PreProcessProgressCallback progress: ProgressCallback 'reset-progress': GenericEventCallback - restored: GenericEventCallback + restored: (pluginData: any) => void 'restore-confirmed': GenericEventCallback + 'restore-canceled': GenericEventCallback 'restriction-failed': RestrictionFailedCallback 'resume-all': GenericEventCallback 'retry-all': RetryAllCallback @@ -635,7 +638,7 @@ export class Uppy { // @todo next major: rename to `clear()`, make it also cancel ongoing uploads // or throw and say you need to cancel manually - protected clearUploadedFiles(): void { + clearUploadedFiles(): void { this.setState({ ...defaultUploadState, files: {} }) } @@ -1713,7 +1716,7 @@ export class Uppy { #updateOnlineStatus = this.updateOnlineStatus.bind(this) - getID(): UppyOptions['id'] { + getID(): string { return this.opts.id } diff --git a/packages/@uppy/core/src/index.ts b/packages/@uppy/core/src/index.ts index ca8462d6c92..6afec75f44e 100644 --- a/packages/@uppy/core/src/index.ts +++ b/packages/@uppy/core/src/index.ts @@ -1,11 +1,12 @@ export { default } from './Uppy.ts' export { default as Uppy, - type UppyEventMap, type State, type UnknownPlugin, type UnknownProviderPlugin, type UnknownSearchProviderPlugin, + type UploadResult, + type UppyEventMap, } from './Uppy.ts' export { default as UIPlugin } from './UIPlugin.ts' export { default as BasePlugin } from './BasePlugin.ts' From 7f73a5cf1aadb61f7e042a28bce4c175f712a5de Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 14 Mar 2024 19:13:32 +0200 Subject: [PATCH 06/12] meta: fix `resize-observer-polyfill` types (#4994) The upstream package is defining its own types which are incompatible with TS built-in DOM types. --- ...server-polyfill-npm-1.5.1-603120e8a0.patch | 19 +++++++++++++++++++ package.json | 3 ++- yarn.lock | 9 ++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch diff --git a/.yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch b/.yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch new file mode 100644 index 00000000000..b845cd4d688 --- /dev/null +++ b/.yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch @@ -0,0 +1,19 @@ +diff --git a/src/index.d.ts b/src/index.d.ts +index 74aacc0526ff554e9248c3f6fb44c353b5465efc..1b236d215a9db4cbc1c83f4d8bce24add202483e 100644 +--- a/src/index.d.ts ++++ b/src/index.d.ts +@@ -1,14 +1,3 @@ +-interface DOMRectReadOnly { +- readonly x: number; +- readonly y: number; +- readonly width: number; +- readonly height: number; +- readonly top: number; +- readonly right: number; +- readonly bottom: number; +- readonly left: number; +-} +- + declare global { + interface ResizeObserverCallback { + (entries: ResizeObserverEntry[], observer: ResizeObserver): void diff --git a/package.json b/package.json index ddfa84d6009..4d9fc0b3fbc 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,7 @@ "start-server-and-test": "patch:start-server-and-test@npm:1.14.0#.yarn/patches/start-server-and-test-npm-1.14.0-841aa34fdf.patch", "stylelint-config-rational-order": "patch:stylelint-config-rational-order@npm%3A0.1.2#./.yarn/patches/stylelint-config-rational-order-npm-0.1.2-d8336e84ed.patch", "uuid@^8.3.2": "patch:uuid@npm:8.3.2#.yarn/patches/uuid-npm-8.3.2-eca0baba53.patch", - "tus-js-client": "patch:tus-js-client@npm%3A3.1.3#./.yarn/patches/tus-js-client-npm-3.1.3-dc57874d23.patch" + "tus-js-client": "patch:tus-js-client@npm%3A3.1.3#./.yarn/patches/tus-js-client-npm-3.1.3-dc57874d23.patch", + "resize-observer-polyfill": "patch:resize-observer-polyfill@npm%3A1.5.1#./.yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch" } } diff --git a/yarn.lock b/yarn.lock index 5a67a43aca1..b316e06a68d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27391,13 +27391,20 @@ __metadata: languageName: node linkType: hard -"resize-observer-polyfill@npm:^1.5.0, resize-observer-polyfill@npm:^1.5.1": +"resize-observer-polyfill@npm:1.5.1": version: 1.5.1 resolution: "resize-observer-polyfill@npm:1.5.1" checksum: 57e7f79489867b00ba43c9c051524a5c8f162a61d5547e99333549afc23e15c44fd43f2f318ea0261ea98c0eb3158cca261e6f48d66e1ed1cd1f340a43977094 languageName: node linkType: hard +"resize-observer-polyfill@patch:resize-observer-polyfill@npm%3A1.5.1#./.yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch::locator=%40uppy-dev%2Fbuild%40workspace%3A.": + version: 1.5.1 + resolution: "resize-observer-polyfill@patch:resize-observer-polyfill@npm%3A1.5.1#./.yarn/patches/resize-observer-polyfill-npm-1.5.1-603120e8a0.patch::version=1.5.1&hash=9a18c0&locator=%40uppy-dev%2Fbuild%40workspace%3A." + checksum: 77fe421d8aeb31ec823494ed7c7f5011fcdf83a6abba9020834c2f5c2e1e79ddf5d95e8db16b85d17022b59816553cd1749f9b00ab5522e766ccb5641338f2fe + languageName: node + linkType: hard + "resolve-alpn@npm:^1.0.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" From 1f4f52feb4c7e2c0cf2e785c91ddb1f9f3539492 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:44:58 +0100 Subject: [PATCH 07/12] Bump follow-redirects from 1.15.4 to 1.15.6 (#5002) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b316e06a68d..76726e322e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16868,12 +16868,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.15.0": - version: 1.15.4 - resolution: "follow-redirects@npm:1.15.4" + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" peerDependenciesMeta: debug: optional: true - checksum: e178d1deff8b23d5d24ec3f7a94cde6e47d74d0dc649c35fc9857041267c12ec5d44650a0c5597ef83056ada9ea6ca0c30e7c4f97dbf07d035086be9e6a5b7b6 + checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5 languageName: node linkType: hard From 8ae90c5b110cc8132c832681b0938606bd28d1ab Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Mon, 18 Mar 2024 14:23:24 +0100 Subject: [PATCH 08/12] @uppy/golden-retriever: migrate to TS (#4989) Co-authored-by: Antoine du Hamel --- packages/@uppy/golden-retriever/.npmignore | 1 + .../{IndexedDBStore.js => IndexedDBStore.ts} | 142 +++++---- .../{MetaDataStore.js => MetaDataStore.ts} | 50 ++- ...ceWorkerStore.js => ServiceWorkerStore.ts} | 51 ++- .../@uppy/golden-retriever/src/cleanup.js | 10 - .../@uppy/golden-retriever/src/cleanup.ts | 10 + .../src/{index.js => index.ts} | 295 ++++++++++++------ .../golden-retriever/tsconfig.build.json | 25 ++ packages/@uppy/golden-retriever/tsconfig.json | 21 ++ 9 files changed, 396 insertions(+), 209 deletions(-) create mode 100644 packages/@uppy/golden-retriever/.npmignore rename packages/@uppy/golden-retriever/src/{IndexedDBStore.js => IndexedDBStore.ts} (62%) rename packages/@uppy/golden-retriever/src/{MetaDataStore.js => MetaDataStore.ts} (59%) rename packages/@uppy/golden-retriever/src/{ServiceWorkerStore.js => ServiceWorkerStore.ts} (53%) delete mode 100644 packages/@uppy/golden-retriever/src/cleanup.js create mode 100644 packages/@uppy/golden-retriever/src/cleanup.ts rename packages/@uppy/golden-retriever/src/{index.js => index.ts} (55%) create mode 100644 packages/@uppy/golden-retriever/tsconfig.build.json create mode 100644 packages/@uppy/golden-retriever/tsconfig.json diff --git a/packages/@uppy/golden-retriever/.npmignore b/packages/@uppy/golden-retriever/.npmignore new file mode 100644 index 00000000000..6c816673f08 --- /dev/null +++ b/packages/@uppy/golden-retriever/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/golden-retriever/src/IndexedDBStore.js b/packages/@uppy/golden-retriever/src/IndexedDBStore.ts similarity index 62% rename from packages/@uppy/golden-retriever/src/IndexedDBStore.js rename to packages/@uppy/golden-retriever/src/IndexedDBStore.ts index 4a336425800..59b691d9289 100644 --- a/packages/@uppy/golden-retriever/src/IndexedDBStore.js +++ b/packages/@uppy/golden-retriever/src/IndexedDBStore.ts @@ -1,8 +1,16 @@ -/** - * @type {typeof window.indexedDB} - */ -const indexedDB = typeof window !== 'undefined' - && (window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.OIndexedDB || window.msIndexedDB) +import type { UppyFile } from '@uppy/utils/lib/UppyFile' + +const indexedDB = + typeof window !== 'undefined' && + (window.indexedDB || + // @ts-expect-error unknown + window.webkitIndexedDB || + // @ts-expect-error unknown + window.mozIndexedDB || + // @ts-expect-error unknown + window.OIndexedDB || + // @ts-expect-error unknown + window.msIndexedDB) const isSupported = !!indexedDB @@ -14,13 +22,11 @@ const MiB = 0x10_00_00 /** * Set default `expires` dates on existing stored blobs. - * - * @param {IDBObjectStore} store */ -function migrateExpiration (store) { +function migrateExpiration(store: IDBObjectStore) { const request = store.openCursor() request.onsuccess = (event) => { - const cursor = event.target.result + const cursor = (event.target as IDBRequest).result if (!cursor) { return } @@ -30,22 +36,14 @@ function migrateExpiration (store) { } } -/** - * @param {string} dbName - * @returns {Promise} - */ -function connect (dbName) { - const request = indexedDB.open(dbName, DB_VERSION) +function connect(dbName: string): Promise { + const request = (indexedDB as IDBFactory).open(dbName, DB_VERSION) return new Promise((resolve, reject) => { request.onupgradeneeded = (event) => { - /** - * @type {IDBDatabase} - */ - const db = event.target.result - /** - * @type {IDBTransaction} - */ - const { transaction } = event.currentTarget + const db: IDBDatabase = (event.target as IDBOpenDBRequest).result + // eslint-disable-next-line prefer-destructuring + const transaction = (event.currentTarget as IDBOpenDBRequest) + .transaction as IDBTransaction if (event.oldVersion < 2) { // Added in v2: DB structure changed to a single shared object store @@ -66,34 +64,48 @@ function connect (dbName) { } } request.onsuccess = (event) => { - resolve(event.target.result) + resolve((event.target as IDBRequest).result) } request.onerror = reject }) } -/** - * @template T - * @param {IDBRequest} request - * @returns {Promise} - */ -function waitForRequest (request) { +function waitForRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = (event) => { - resolve(event.target.result) + resolve((event.target as IDBRequest).result) } request.onerror = reject }) } +type IndexedDBStoredFile = { + id: string + fileID: string + store: string + expires: number + data: Blob +} + +type IndexedDBStoreOptions = { + dbName?: string + storeName?: string + expires?: number + maxFileSize?: number + maxTotalSize?: number +} + let cleanedUp = false class IndexedDBStore { - /** - * @type {Promise | IDBDatabase} - */ - #ready + #ready: Promise | IDBDatabase - constructor (opts) { + opts: Required + + name: string + + static isSupported: boolean + + constructor(opts?: IndexedDBStoreOptions) { this.opts = { dbName: DB_NAME, storeName: 'default', @@ -113,48 +125,50 @@ class IndexedDBStore { if (!cleanedUp) { cleanedUp = true - this.#ready = IndexedDBStore.cleanup() - .then(createConnection, createConnection) + this.#ready = IndexedDBStore.cleanup().then( + createConnection, + createConnection, + ) } else { this.#ready = createConnection() } } - get ready () { + get ready(): Promise { return Promise.resolve(this.#ready) } // TODO: remove this setter in the next major - set ready (val) { + set ready(val: IDBDatabase) { this.#ready = val } - key (fileID) { + key(fileID: string): string { return `${this.name}!${fileID}` } /** * List all file blobs currently in the store. */ - async list () { + async list(): Promise> { const db = await this.#ready const transaction = db.transaction([STORE_NAME], 'readonly') const store = transaction.objectStore(STORE_NAME) - const request = store.index('store') - .getAll(IDBKeyRange.only(this.name)) - const files = await waitForRequest(request) - return Object.fromEntries(files.map(file => [file.fileID, file.data])) + const request = store.index('store').getAll(IDBKeyRange.only(this.name)) + const files = await waitForRequest(request) + return Object.fromEntries(files.map((file) => [file.fileID, file.data])) } /** * Get one file blob from the store. */ - async get (fileID) { + async get(fileID: string): Promise<{ id: string; data: Blob }> { const db = await this.#ready const transaction = db.transaction([STORE_NAME], 'readonly') - const request = transaction.objectStore(STORE_NAME) - .get(this.key(fileID)) - const { data } = await waitForRequest(request) + const request = transaction.objectStore(STORE_NAME).get(this.key(fileID)) + const { data } = await waitForRequest<{ + data: { data: Blob; fileID: string } + }>(request) return { id: data.fileID, data: data.data, @@ -163,20 +177,16 @@ class IndexedDBStore { /** * Get the total size of all stored files. - * - * @private - * @returns {Promise} */ - async getSize () { + async getSize(): Promise { const db = await this.#ready const transaction = db.transaction([STORE_NAME], 'readonly') const store = transaction.objectStore(STORE_NAME) - const request = store.index('store') - .openCursor(IDBKeyRange.only(this.name)) + const request = store.index('store').openCursor(IDBKeyRange.only(this.name)) return new Promise((resolve, reject) => { let size = 0 request.onsuccess = (event) => { - const cursor = event.target.result + const cursor = (event.target as IDBRequest).result if (cursor) { size += cursor.value.data.size cursor.continue() @@ -193,7 +203,7 @@ class IndexedDBStore { /** * Save a file in the store. */ - async put (file) { + async put(file: UppyFile): Promise { if (file.data.size > this.opts.maxFileSize) { throw new Error('File is too big to store.') } @@ -201,7 +211,7 @@ class IndexedDBStore { if (size > this.opts.maxTotalSize) { throw new Error('No space left') } - const db = this.#ready + const db = await this.#ready const transaction = db.transaction([STORE_NAME], 'readwrite') const request = transaction.objectStore(STORE_NAME).add({ id: this.key(file.id), @@ -216,11 +226,10 @@ class IndexedDBStore { /** * Delete a file blob from the store. */ - async delete (fileID) { + async delete(fileID: string): Promise { const db = await this.#ready const transaction = db.transaction([STORE_NAME], 'readwrite') - const request = transaction.objectStore(STORE_NAME) - .delete(this.key(fileID)) + const request = transaction.objectStore(STORE_NAME).delete(this.key(fileID)) return waitForRequest(request) } @@ -228,15 +237,16 @@ class IndexedDBStore { * Delete all stored blobs that have an expiry date that is before Date.now(). * This is a static method because it deletes expired blobs from _all_ Uppy instances. */ - static async cleanup () { + static async cleanup(): Promise { const db = await connect(DB_NAME) const transaction = db.transaction([STORE_NAME], 'readwrite') const store = transaction.objectStore(STORE_NAME) - const request = store.index('expires') + const request = store + .index('expires') .openCursor(IDBKeyRange.upperBound(Date.now())) - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { request.onsuccess = (event) => { - const cursor = event.target.result + const cursor = (event.target as IDBRequest).result if (cursor) { cursor.delete() // Ignoring return value … it's not terrible if this goes wrong. cursor.continue() diff --git a/packages/@uppy/golden-retriever/src/MetaDataStore.js b/packages/@uppy/golden-retriever/src/MetaDataStore.ts similarity index 59% rename from packages/@uppy/golden-retriever/src/MetaDataStore.js rename to packages/@uppy/golden-retriever/src/MetaDataStore.ts index 87928c94187..5435964e45b 100644 --- a/packages/@uppy/golden-retriever/src/MetaDataStore.js +++ b/packages/@uppy/golden-retriever/src/MetaDataStore.ts @@ -1,11 +1,23 @@ +import type { State as UppyState } from '@uppy/core' +import type { Meta, Body } from '@uppy/utils/lib/UppyFile' + +export type StoredState = { + expires: number + metadata: { + currentUploads: UppyState['currentUploads'] + files: UppyState['files'] + pluginData: Record + } +} + /** * Get uppy instance IDs for which state is stored. */ -function findUppyInstances () { - const instances = [] +function findUppyInstances(): string[] { + const instances: string[] = [] for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) - if (key.startsWith('uppyState:')) { + if (key?.startsWith('uppyState:')) { instances.push(key.slice('uppyState:'.length)) } } @@ -15,7 +27,9 @@ function findUppyInstances () { /** * Try to JSON-parse a string, return null on failure. */ -function maybeParse (str) { +function maybeParse( + str: string, +): StoredState | null { try { return JSON.parse(str) } catch { @@ -23,9 +37,18 @@ function maybeParse (str) { } } +type MetaDataStoreOptions = { + storeName: string + expires?: number +} + let cleanedUp = false -export default class MetaDataStore { - constructor (opts) { +export default class MetaDataStore { + opts: Required + + name: string + + constructor(opts: MetaDataStoreOptions) { this.opts = { expires: 24 * 60 * 60 * 1000, // 24 hours ...opts, @@ -41,23 +64,16 @@ export default class MetaDataStore { /** * */ - load () { + load(): StoredState['metadata'] | null { const savedState = localStorage.getItem(this.name) if (!savedState) return null - const data = maybeParse(savedState) + const data = maybeParse(savedState) if (!data) return null - // Upgrade pre-0.20.0 uppyState: it used to be just a flat object, - // without `expires`. - if (!data.metadata) { - this.save(data) - return data - } - return data.metadata } - save (metadata) { + save(metadata: Record): void { const expires = Date.now() + this.opts.expires const state = JSON.stringify({ metadata, @@ -69,7 +85,7 @@ export default class MetaDataStore { /** * Remove all expired state. */ - static cleanup (instanceID) { + static cleanup(instanceID?: string): void { if (instanceID) { localStorage.removeItem(`uppyState:${instanceID}`) return diff --git a/packages/@uppy/golden-retriever/src/ServiceWorkerStore.js b/packages/@uppy/golden-retriever/src/ServiceWorkerStore.ts similarity index 53% rename from packages/@uppy/golden-retriever/src/ServiceWorkerStore.js rename to packages/@uppy/golden-retriever/src/ServiceWorkerStore.ts index e5c5570d21b..370fbc5391c 100644 --- a/packages/@uppy/golden-retriever/src/ServiceWorkerStore.js +++ b/packages/@uppy/golden-retriever/src/ServiceWorkerStore.ts @@ -1,7 +1,10 @@ -const isSupported = typeof navigator !== 'undefined' && 'serviceWorker' in navigator +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' -function waitForServiceWorker () { - return new Promise((resolve, reject) => { +const isSupported = + typeof navigator !== 'undefined' && 'serviceWorker' in navigator + +function waitForServiceWorker() { + return new Promise((resolve, reject) => { if (!isSupported) { reject(new Error('Unsupported')) } else if (navigator.serviceWorker.controller) { @@ -15,28 +18,44 @@ function waitForServiceWorker () { }) } -class ServiceWorkerStore { - #ready +export type ServiceWorkerStoredFile = { + type: string + store: string + file: UppyFile +} + +type ServiceWorkerStoreOptions = { + storeName: string +} + +class ServiceWorkerStore { + #ready: void | Promise + + name: string - constructor (opts) { - this.#ready = waitForServiceWorker().then((val) => { this.#ready = val }) + static isSupported: boolean + + constructor(opts: ServiceWorkerStoreOptions) { + this.#ready = waitForServiceWorker().then((val) => { + this.#ready = val + }) this.name = opts.storeName } - get ready () { + get ready(): Promise { return Promise.resolve(this.#ready) } // TODO: remove this setter in the next major - set ready (val) { + set ready(val: void) { this.#ready = val } - async list () { + async list(): Promise[]> { await this.#ready return new Promise((resolve, reject) => { - const onMessage = (event) => { + const onMessage = (event: MessageEvent) => { if (event.data.store !== this.name) { return } @@ -52,25 +71,25 @@ class ServiceWorkerStore { navigator.serviceWorker.addEventListener('message', onMessage) - navigator.serviceWorker.controller.postMessage({ + navigator.serviceWorker.controller!.postMessage({ type: 'uppy/GET_FILES', store: this.name, }) }) } - async put (file) { + async put(file: UppyFile): Promise { await this.#ready - navigator.serviceWorker.controller.postMessage({ + navigator.serviceWorker.controller!.postMessage({ type: 'uppy/ADD_FILE', store: this.name, file, }) } - async delete (fileID) { + async delete(fileID: string): Promise { await this.#ready - navigator.serviceWorker.controller.postMessage({ + navigator.serviceWorker.controller!.postMessage({ type: 'uppy/REMOVE_FILE', store: this.name, fileID, diff --git a/packages/@uppy/golden-retriever/src/cleanup.js b/packages/@uppy/golden-retriever/src/cleanup.js deleted file mode 100644 index 28a862305fd..00000000000 --- a/packages/@uppy/golden-retriever/src/cleanup.js +++ /dev/null @@ -1,10 +0,0 @@ -import IndexedDBStore from './IndexedDBStore.js' -import MetaDataStore from './MetaDataStore.js' - -/** - * Clean old blobs without needing to import all of Uppy. - */ -export default function cleanup () { - MetaDataStore.cleanup() - IndexedDBStore.cleanup() -} diff --git a/packages/@uppy/golden-retriever/src/cleanup.ts b/packages/@uppy/golden-retriever/src/cleanup.ts new file mode 100644 index 00000000000..fc17e2f962f --- /dev/null +++ b/packages/@uppy/golden-retriever/src/cleanup.ts @@ -0,0 +1,10 @@ +import IndexedDBStore from './IndexedDBStore.ts' +import MetaDataStore from './MetaDataStore.ts' + +/** + * Clean old blobs without needing to import all of Uppy. + */ +export default function cleanup(): void { + MetaDataStore.cleanup() + IndexedDBStore.cleanup() +} diff --git a/packages/@uppy/golden-retriever/src/index.js b/packages/@uppy/golden-retriever/src/index.ts similarity index 55% rename from packages/@uppy/golden-retriever/src/index.js rename to packages/@uppy/golden-retriever/src/index.ts index 6b724edc2c8..e1992b8bf05 100644 --- a/packages/@uppy/golden-retriever/src/index.js +++ b/packages/@uppy/golden-retriever/src/index.ts @@ -1,11 +1,46 @@ import throttle from 'lodash/throttle.js' import BasePlugin from '@uppy/core/lib/BasePlugin.js' -import ServiceWorkerStore from './ServiceWorkerStore.js' -import IndexedDBStore from './IndexedDBStore.js' -import MetaDataStore from './MetaDataStore.js' - +import type { PluginOpts, DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type Uppy from '@uppy/core' +import type { UploadResult } from '@uppy/core' +import ServiceWorkerStore, { + type ServiceWorkerStoredFile, +} from './ServiceWorkerStore.ts' +import IndexedDBStore from './IndexedDBStore.ts' +import MetaDataStore from './MetaDataStore.ts' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' +declare module '@uppy/core' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface UppyEventMap { + // TODO: remove this event + 'restore:get-data': (fn: (data: Record) => void) => void + } +} + +export interface GoldenRetrieverOptions extends PluginOpts { + expires?: number + serviceWorker?: boolean + indexedDB?: { + name?: string + version?: number + } +} + +const defaultOptions = { + expires: 24 * 60 * 60 * 1000, // 24 hours + serviceWorker: false, +} + +type Opts = DefinePluginOpts< + GoldenRetrieverOptions, + keyof typeof defaultOptions +> + /** * The GoldenRetriever plugin — restores selected files and resumes uploads * after a closed tab or a browser crash! @@ -13,21 +48,24 @@ import packageJson from '../package.json' * Uses localStorage, IndexedDB and ServiceWorker to do its magic, read more: * https://uppy.io/blog/2017/07/golden-retriever/ */ -export default class GoldenRetriever extends BasePlugin { +export default class GoldenRetriever< + M extends Meta, + B extends Body, +> extends BasePlugin { static VERSION = packageJson.version - constructor (uppy, opts) { - super(uppy, opts) - this.type = 'debugger' - this.id = this.opts.id || 'GoldenRetriever' - this.title = 'Golden Retriever' + MetaDataStore: MetaDataStore - const defaultOptions = { - expires: 24 * 60 * 60 * 1000, // 24 hours - serviceWorker: false, - } + ServiceWorkerStore: ServiceWorkerStore | null - this.opts = { ...defaultOptions, ...opts } + IndexedDBStore: IndexedDBStore + + savedPluginData: Record + + constructor(uppy: Uppy, opts?: GoldenRetrieverOptions) { + super(uppy, { ...defaultOptions, ...opts }) + this.type = 'debugger' + this.id = this.opts.id || 'GoldenRetriever' this.MetaDataStore = new MetaDataStore({ expires: this.opts.expires, @@ -35,11 +73,13 @@ export default class GoldenRetriever extends BasePlugin { }) this.ServiceWorkerStore = null if (this.opts.serviceWorker) { - this.ServiceWorkerStore = new ServiceWorkerStore({ storeName: uppy.getID() }) + this.ServiceWorkerStore = new ServiceWorkerStore({ + storeName: uppy.getID(), + }) } this.IndexedDBStore = new IndexedDBStore({ expires: this.opts.expires, - ...this.opts.indexedDB || {}, + ...(this.opts.indexedDB || {}), storeName: uppy.getID(), }) @@ -49,12 +89,13 @@ export default class GoldenRetriever extends BasePlugin { { leading: true, trailing: true }, ) this.restoreState = this.restoreState.bind(this) - this.loadFileBlobsFromServiceWorker = this.loadFileBlobsFromServiceWorker.bind(this) + this.loadFileBlobsFromServiceWorker = + this.loadFileBlobsFromServiceWorker.bind(this) this.loadFileBlobsFromIndexedDB = this.loadFileBlobsFromIndexedDB.bind(this) this.onBlobsLoaded = this.onBlobsLoaded.bind(this) } - restoreState () { + restoreState(): void { const savedState = this.MetaDataStore.load() if (savedState) { this.uppy.log('[GoldenRetriever] Recovered some state from Local Storage') @@ -71,8 +112,8 @@ export default class GoldenRetriever extends BasePlugin { * Get file objects that are currently waiting: they've been selected, * but aren't yet being uploaded. */ - getWaitingFiles () { - const waitingFiles = {} + getWaitingFiles(): Record> { + const waitingFiles: Record> = {} this.uppy.getFiles().forEach((file) => { if (!file.progress || !file.progress.uploadStarted) { @@ -88,8 +129,8 @@ export default class GoldenRetriever extends BasePlugin { * uploading, but the other files in the same batch have not, the finished * file is also returned. */ - getUploadingFiles () { - const uploadingFiles = {} + getUploadingFiles(): Record> { + const uploadingFiles: Record> = {} const { currentUploads } = this.uppy.getState() if (currentUploads) { @@ -105,7 +146,7 @@ export default class GoldenRetriever extends BasePlugin { return uploadingFiles } - saveFilesStateToLocalStorage () { + saveFilesStateToLocalStorage(): void { const filesToSave = { ...this.getWaitingFiles(), ...this.getUploadingFiles(), @@ -124,21 +165,25 @@ export default class GoldenRetriever extends BasePlugin { // We dont’t need to store file.data on local files, because the actual blob will be restored later, // and we want to avoid having weird properties in the serialized object. // Also adding file.isRestored to all files, since they will be restored from local storage - const filesToSaveWithoutData = Object.fromEntries(fileToSaveEntries.map(([id, fileInfo]) => [id, fileInfo.isRemote - ? { - ...fileInfo, - isRestored: true, - } - : { - ...fileInfo, - isRestored: true, - data: null, - preview: null, - }, - ])) + const filesToSaveWithoutData = Object.fromEntries( + fileToSaveEntries.map(([id, fileInfo]) => [ + id, + fileInfo.isRemote ? + { + ...fileInfo, + isRestored: true, + } + : { + ...fileInfo, + isRestored: true, + data: null, + preview: null, + }, + ]), + ) const pluginData = {} - // TODO Find a better way to do this? + // TODO Remove this, // Other plugins can attach a restore:get-data listener that receives this callback. // Plugins can then use this callback (sync) to provide data to be stored. this.uppy.emit('restore:get-data', (data) => { @@ -154,46 +199,64 @@ export default class GoldenRetriever extends BasePlugin { }) } - loadFileBlobsFromServiceWorker () { + loadFileBlobsFromServiceWorker(): Promise< + ServiceWorkerStoredFile | Record + > { if (!this.ServiceWorkerStore) { return Promise.resolve({}) } - return this.ServiceWorkerStore.list().then((blobs) => { - const numberOfFilesRecovered = Object.keys(blobs).length + return this.ServiceWorkerStore.list() + .then((blobs) => { + const numberOfFilesRecovered = Object.keys(blobs).length - if (numberOfFilesRecovered > 0) { - this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`) - return blobs - } - this.uppy.log('[GoldenRetriever] No blobs found in Service Worker, trying IndexedDB now...') - return {} - }).catch((err) => { - this.uppy.log('[GoldenRetriever] Failed to recover blobs from Service Worker', 'warning') - this.uppy.log(err) - return {} - }) + if (numberOfFilesRecovered > 0) { + this.uppy.log( + `[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`, + ) + return blobs + } + this.uppy.log( + '[GoldenRetriever] No blobs found in Service Worker, trying IndexedDB now...', + ) + return {} + }) + .catch((err) => { + this.uppy.log( + '[GoldenRetriever] Failed to recover blobs from Service Worker', + 'warning', + ) + this.uppy.log(err) + return {} + }) } - loadFileBlobsFromIndexedDB () { - return this.IndexedDBStore.list().then((blobs) => { - const numberOfFilesRecovered = Object.keys(blobs).length + loadFileBlobsFromIndexedDB(): ReturnType { + return this.IndexedDBStore.list() + .then((blobs) => { + const numberOfFilesRecovered = Object.keys(blobs).length - if (numberOfFilesRecovered > 0) { - this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from IndexedDB!`) - return blobs - } - this.uppy.log('[GoldenRetriever] No blobs found in IndexedDB') - return {} - }).catch((err) => { - this.uppy.log('[GoldenRetriever] Failed to recover blobs from IndexedDB', 'warning') - this.uppy.log(err) - return {} - }) + if (numberOfFilesRecovered > 0) { + this.uppy.log( + `[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from IndexedDB!`, + ) + return blobs + } + this.uppy.log('[GoldenRetriever] No blobs found in IndexedDB') + return {} + }) + .catch((err) => { + this.uppy.log( + '[GoldenRetriever] Failed to recover blobs from IndexedDB', + 'warning', + ) + this.uppy.log(err) + return {} + }) } - onBlobsLoaded (blobs) { - const obsoleteBlobs = [] + onBlobsLoaded(blobs: Record): void { + const obsoleteBlobs: string[] = [] const updatedFiles = { ...this.uppy.getState().files } // Loop through blobs that we can restore, add blobs to file objects @@ -232,20 +295,33 @@ export default class GoldenRetriever extends BasePlugin { this.uppy.emit('restored', this.savedPluginData) if (obsoleteBlobs.length) { - this.deleteBlobs(obsoleteBlobs).then(() => { - this.uppy.log(`[GoldenRetriever] Cleaned up ${obsoleteBlobs.length} old files`) - }).catch((err) => { - this.uppy.log(`[GoldenRetriever] Could not clean up ${obsoleteBlobs.length} old files`, 'warning') - this.uppy.log(err) - }) + this.deleteBlobs(obsoleteBlobs) + .then(() => { + this.uppy.log( + `[GoldenRetriever] Cleaned up ${obsoleteBlobs.length} old files`, + ) + }) + .catch((err) => { + this.uppy.log( + `[GoldenRetriever] Could not clean up ${obsoleteBlobs.length} old files`, + 'warning', + ) + this.uppy.log(err) + }) } } - deleteBlobs (fileIDs) { - return Promise.all(fileIDs.map(id => this.ServiceWorkerStore?.delete(id) ?? this.IndexedDBStore?.delete(id))) + async deleteBlobs(fileIDs: string[]): Promise { + await Promise.all( + fileIDs.map( + (id) => + this.ServiceWorkerStore?.delete(id) ?? + this.IndexedDBStore?.delete(id), + ), + ) } - addBlobToStores = (file) => { + addBlobToStores = (file: UppyFile): void => { if (file.isRemote) return if (this.ServiceWorkerStore) { @@ -261,7 +337,7 @@ export default class GoldenRetriever extends BasePlugin { }) } - removeBlobFromStores = (file) => { + removeBlobFromStores = (file: UppyFile): void => { if (this.ServiceWorkerStore) { this.ServiceWorkerStore.delete(file.id).catch((err) => { this.uppy.log('[GoldenRetriever] Failed to remove file', 'warning') @@ -274,72 +350,90 @@ export default class GoldenRetriever extends BasePlugin { }) } - replaceBlobInStores = (file) => { + replaceBlobInStores = (file: UppyFile): void => { this.removeBlobFromStores(file) this.addBlobToStores(file) } - handleRestoreConfirmed = () => { + handleRestoreConfirmed = (): void => { this.uppy.log('[GoldenRetriever] Restore confirmed, proceeding...') // start all uploads again when file blobs are restored const { currentUploads } = this.uppy.getState() if (currentUploads) { this.uppy.resumeAll() Object.keys(currentUploads).forEach((uploadId) => { - this.uppy.restore(uploadId, currentUploads[uploadId]) + this.uppy.restore(uploadId) }) } this.uppy.setState({ recoveredState: null }) } - abortRestore = () => { + abortRestore = (): void => { this.uppy.log('[GoldenRetriever] Aborting restore...') const fileIDs = Object.keys(this.uppy.getState().files) - this.deleteBlobs(fileIDs).then(() => { - this.uppy.log(`[GoldenRetriever] Removed ${fileIDs.length} files`) - }).catch((err) => { - this.uppy.log(`[GoldenRetriever] Could not remove ${fileIDs.length} files`, 'warning') - this.uppy.log(err) - }) + this.deleteBlobs(fileIDs) + .then(() => { + this.uppy.log(`[GoldenRetriever] Removed ${fileIDs.length} files`) + }) + .catch((err) => { + this.uppy.log( + `[GoldenRetriever] Could not remove ${fileIDs.length} files`, + 'warning', + ) + this.uppy.log(err) + }) this.uppy.cancelAll() this.uppy.setState({ recoveredState: null }) MetaDataStore.cleanup(this.uppy.opts.id) } - handleComplete = ({ successful }) => { - const fileIDs = successful.map((file) => file.id) - this.deleteBlobs(fileIDs).then(() => { - this.uppy.log(`[GoldenRetriever] Removed ${successful.length} files that finished uploading`) - }).catch((err) => { - this.uppy.log(`[GoldenRetriever] Could not remove ${successful.length} files that finished uploading`, 'warning') - this.uppy.log(err) - }) + handleComplete = ({ successful }: UploadResult): void => { + const fileIDs = successful!.map((file) => file.id) + this.deleteBlobs(fileIDs) + .then(() => { + this.uppy.log( + `[GoldenRetriever] Removed ${successful!.length} files that finished uploading`, + ) + }) + .catch((err) => { + this.uppy.log( + `[GoldenRetriever] Could not remove ${successful!.length} files that finished uploading`, + 'warning', + ) + this.uppy.log(err) + }) this.uppy.setState({ recoveredState: null }) MetaDataStore.cleanup(this.uppy.opts.id) } - restoreBlobs = () => { + restoreBlobs = (): void => { if (this.uppy.getFiles().length > 0) { Promise.all([ this.loadFileBlobsFromServiceWorker(), this.loadFileBlobsFromIndexedDB(), ]).then((resultingArrayOfObjects) => { - const blobs = { ...resultingArrayOfObjects[0], ...resultingArrayOfObjects[1] } + const blobs = { + ...resultingArrayOfObjects[0], + ...resultingArrayOfObjects[1], + } as Record this.onBlobsLoaded(blobs) }) } else { - this.uppy.log('[GoldenRetriever] No files need to be loaded, only restoring processing state...') + this.uppy.log( + '[GoldenRetriever] No files need to be loaded, only restoring processing state...', + ) } } - install () { + install(): void { this.restoreState() this.restoreBlobs() this.uppy.on('file-added', this.addBlobToStores) + // @ts-expect-error this is typed in @uppy/image-editor and we can't access those types. this.uppy.on('file-editor:complete', this.replaceBlobInStores) this.uppy.on('file-removed', this.removeBlobFromStores) // TODO: the `state-update` is bad practise. It fires on any state change in Uppy @@ -351,8 +445,9 @@ export default class GoldenRetriever extends BasePlugin { this.uppy.on('complete', this.handleComplete) } - uninstall () { + uninstall(): void { this.uppy.off('file-added', this.addBlobToStores) + // @ts-expect-error this is typed in @uppy/image-editor and we can't access those types. this.uppy.off('file-editor:complete', this.replaceBlobInStores) this.uppy.off('file-removed', this.removeBlobFromStores) this.uppy.off('state-update', this.saveFilesStateToLocalStorage) diff --git a/packages/@uppy/golden-retriever/tsconfig.build.json b/packages/@uppy/golden-retriever/tsconfig.build.json new file mode 100644 index 00000000000..1b0ca41093a --- /dev/null +++ b/packages/@uppy/golden-retriever/tsconfig.build.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/golden-retriever/tsconfig.json b/packages/@uppy/golden-retriever/tsconfig.json new file mode 100644 index 00000000000..a76c3b714a8 --- /dev/null +++ b/packages/@uppy/golden-retriever/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + ], +} From 644fe50f93dd0826f89d82f7070f444a1b31946e Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 18 Mar 2024 16:27:56 +0200 Subject: [PATCH 09/12] @uppy/utils: fix `RateLimitedQueue#wrapPromiseFunction` types (#5007) --- packages/@uppy/utils/src/RateLimitedQueue.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@uppy/utils/src/RateLimitedQueue.ts b/packages/@uppy/utils/src/RateLimitedQueue.ts index 2948263c43d..52abae3ce89 100644 --- a/packages/@uppy/utils/src/RateLimitedQueue.ts +++ b/packages/@uppy/utils/src/RateLimitedQueue.ts @@ -187,7 +187,9 @@ export class RateLimitedQueue { fn: T, queueOptions?: QueueOptions, ) { - return (...args: Parameters): AbortablePromise> => { + return ( + ...args: Parameters + ): AbortablePromise>> => { let queuedRequest: ReturnType const outerPromise = new Promise((resolve, reject) => { queuedRequest = this.run(() => { @@ -222,7 +224,7 @@ export class RateLimitedQueue { cancelError = createCancelError(cause) } }, queueOptions) - }) as AbortablePromise> + }) as AbortablePromise>> outerPromise.abort = (cause) => { queuedRequest.abort(cause) From 700944e25b8088305ff7f3187a4a9395f310e229 Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Mon, 18 Mar 2024 15:45:11 +0100 Subject: [PATCH 10/12] @uppy/transloadit: migrate to TS (#4987) Co-authored-by: Antoine du Hamel --- packages/@uppy/core/src/Uppy.ts | 5 +- packages/@uppy/transloadit/.npmignore | 1 + .../@uppy/transloadit/src/Assembly.test.js | 127 +-- .../src/{Assembly.js => Assembly.ts} | 106 ++- .../transloadit/src/AssemblyOptions.test.js | 104 ++- ...{AssemblyOptions.js => AssemblyOptions.ts} | 69 +- ...{AssemblyWatcher.js => AssemblyWatcher.ts} | 48 +- packages/@uppy/transloadit/src/Client.js | 213 ----- packages/@uppy/transloadit/src/Client.ts | 280 ++++++ packages/@uppy/transloadit/src/index.test.js | 50 +- .../transloadit/src/{index.js => index.ts} | 800 +++++++++++++----- .../transloadit/src/{locale.js => locale.ts} | 0 .../@uppy/transloadit/tsconfig.build.json | 40 + packages/@uppy/transloadit/tsconfig.json | 36 + packages/@uppy/tus/src/index.ts | 2 + 15 files changed, 1230 insertions(+), 651 deletions(-) create mode 100644 packages/@uppy/transloadit/.npmignore rename packages/@uppy/transloadit/src/{Assembly.js => Assembly.ts} (73%) rename packages/@uppy/transloadit/src/{AssemblyOptions.js => AssemblyOptions.ts} (57%) rename packages/@uppy/transloadit/src/{AssemblyWatcher.js => AssemblyWatcher.ts} (69%) delete mode 100644 packages/@uppy/transloadit/src/Client.js create mode 100644 packages/@uppy/transloadit/src/Client.ts rename packages/@uppy/transloadit/src/{index.js => index.ts} (50%) rename packages/@uppy/transloadit/src/{locale.js => locale.ts} (100%) create mode 100644 packages/@uppy/transloadit/tsconfig.build.json create mode 100644 packages/@uppy/transloadit/tsconfig.json diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index ec338d5812c..e683d7bc747 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -269,13 +269,13 @@ type UploadCompleteCallback = ( result: UploadResult, ) => void type ErrorCallback = ( - error: { message?: string; details?: string }, + error: { name: string; message: string; details?: string }, file?: UppyFile, response?: UppyFile['response'], ) => void type UploadErrorCallback = ( file: UppyFile | undefined, - error: { message: string; details?: string }, + error: { name: string; message: string; details?: string }, response?: | Omit['response']>, 'uploadURL'> | undefined, @@ -789,6 +789,7 @@ export class Uppy { #informAndEmit( errors: { + name: string message: string isUserFacing?: boolean details?: string diff --git a/packages/@uppy/transloadit/.npmignore b/packages/@uppy/transloadit/.npmignore new file mode 100644 index 00000000000..6c816673f08 --- /dev/null +++ b/packages/@uppy/transloadit/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/transloadit/src/Assembly.test.js b/packages/@uppy/transloadit/src/Assembly.test.js index e4637577793..ec5f56511e6 100644 --- a/packages/@uppy/transloadit/src/Assembly.test.js +++ b/packages/@uppy/transloadit/src/Assembly.test.js @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue' -import Assembly from './Assembly.js' +import Assembly from './Assembly.ts' describe('Transloadit/Assembly', () => { describe('status diffing', () => { - function attemptDiff (prev, next) { + function attemptDiff(prev, next) { const assembly = new Assembly(prev, new RateLimitedQueue()) const events = [] assembly.emit = vi.fn((name, ...args) => { @@ -17,43 +17,52 @@ describe('Transloadit/Assembly', () => { } it('ASSEMBLY_UPLOADING → ASSEMBLY_EXECUTING', () => { - const result = attemptDiff({ - ok: 'ASSEMBLY_UPLOADING', - uploads: {}, - results: {}, - }, { - ok: 'ASSEMBLY_EXECUTING', - uploads: {}, - results: {}, - }) + const result = attemptDiff( + { + ok: 'ASSEMBLY_UPLOADING', + uploads: {}, + results: {}, + }, + { + ok: 'ASSEMBLY_EXECUTING', + uploads: {}, + results: {}, + }, + ) expect(result[0]).toEqual(['executing']) }) it('ASSEMBLY_EXECUTING → ASSEMBLY_COMPLETED', () => { - const result = attemptDiff({ - ok: 'ASSEMBLY_EXECUTING', - uploads: {}, - results: {}, - }, { - ok: 'ASSEMBLY_COMPLETED', - uploads: {}, - results: {}, - }) + const result = attemptDiff( + { + ok: 'ASSEMBLY_EXECUTING', + uploads: {}, + results: {}, + }, + { + ok: 'ASSEMBLY_COMPLETED', + uploads: {}, + results: {}, + }, + ) expect(result[0]).toEqual(['finished']) }) it('ASSEMBLY_UPLOADING → ASSEMBLY_COMPLETED', () => { - const result = attemptDiff({ - ok: 'ASSEMBLY_UPLOADING', - uploads: {}, - results: {}, - }, { - ok: 'ASSEMBLY_COMPLETED', - uploads: {}, - results: {}, - }) + const result = attemptDiff( + { + ok: 'ASSEMBLY_UPLOADING', + uploads: {}, + results: {}, + }, + { + ok: 'ASSEMBLY_COMPLETED', + uploads: {}, + results: {}, + }, + ) expect(result[0]).toEqual(['executing']) expect(result[1]).toEqual(['metadata']) @@ -61,33 +70,39 @@ describe('Transloadit/Assembly', () => { }) it('emits events for new files', () => { - const result = attemptDiff({ - ok: 'ASSEMBLY_UPLOADING', - uploads: {}, - results: {}, - }, { - ok: 'ASSEMBLY_UPLOADING', - uploads: { - some_id: { id: 'some_id' }, + const result = attemptDiff( + { + ok: 'ASSEMBLY_UPLOADING', + uploads: {}, + results: {}, }, - results: {}, - }) + { + ok: 'ASSEMBLY_UPLOADING', + uploads: { + some_id: { id: 'some_id' }, + }, + results: {}, + }, + ) expect(result[0]).toEqual(['upload', { id: 'some_id' }]) }) it('emits executing, then upload, on new files + status change', () => { - const result = attemptDiff({ - ok: 'ASSEMBLY_UPLOADING', - uploads: {}, - results: {}, - }, { - ok: 'ASSEMBLY_EXECUTING', - uploads: { - some_id: { id: 'some_id' }, + const result = attemptDiff( + { + ok: 'ASSEMBLY_UPLOADING', + uploads: {}, + results: {}, }, - results: {}, - }) + { + ok: 'ASSEMBLY_EXECUTING', + uploads: { + some_id: { id: 'some_id' }, + }, + results: {}, + }, + ) expect(result[0]).toEqual(['executing']) expect(result[1]).toEqual(['upload', { id: 'some_id' }]) @@ -108,11 +123,7 @@ describe('Transloadit/Assembly', () => { cool_video: { id: 'cool_video' }, }, results: { - step_one: [ - { id: 'thumb1' }, - { id: 'thumb2' }, - { id: 'thumb3' }, - ], + step_one: [{ id: 'thumb1' }, { id: 'thumb2' }, { id: 'thumb3' }], }, } const three = { @@ -127,9 +138,7 @@ describe('Transloadit/Assembly', () => { { id: 'thumb3' }, { id: 'thumb4' }, ], - step_two: [ - { id: 'transcript' }, - ], + step_two: [{ id: 'transcript' }], }, } @@ -162,9 +171,7 @@ describe('Transloadit/Assembly', () => { { id: 'thumb3' }, { id: 'thumb4' }, ], - step_two: [ - { id: 'transcript' }, - ], + step_two: [{ id: 'transcript' }], }, } diff --git a/packages/@uppy/transloadit/src/Assembly.js b/packages/@uppy/transloadit/src/Assembly.ts similarity index 73% rename from packages/@uppy/transloadit/src/Assembly.js rename to packages/@uppy/transloadit/src/Assembly.ts index caddbac8d7f..644d398d668 100644 --- a/packages/@uppy/transloadit/src/Assembly.js +++ b/packages/@uppy/transloadit/src/Assembly.ts @@ -2,16 +2,19 @@ import Emitter from 'component-emitter' import has from '@uppy/utils/lib/hasProperty' import NetworkError from '@uppy/utils/lib/NetworkError' import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore untyped +import type { + RateLimitedQueue, + WrapPromiseFunctionType, +} from '@uppy/utils/lib/RateLimitedQueue' +import type { AssemblyResponse } from '.' const ASSEMBLY_UPLOADING = 'ASSEMBLY_UPLOADING' const ASSEMBLY_EXECUTING = 'ASSEMBLY_EXECUTING' const ASSEMBLY_COMPLETED = 'ASSEMBLY_COMPLETED' -const statusOrder = [ - ASSEMBLY_UPLOADING, - ASSEMBLY_EXECUTING, - ASSEMBLY_COMPLETED, -] +const statusOrder = [ASSEMBLY_UPLOADING, ASSEMBLY_EXECUTING, ASSEMBLY_COMPLETED] /** * Check that an assembly status is equal to or larger than some desired status. @@ -23,20 +26,26 @@ const statusOrder = [ * …so that we can emit the 'executing' event even if the execution step was so * fast that we missed it. */ -function isStatus (status, test) { +function isStatus(status: string, test: string) { return statusOrder.indexOf(status) >= statusOrder.indexOf(test) } class TransloaditAssembly extends Emitter { - #rateLimitedQueue + #rateLimitedQueue: RateLimitedQueue - #fetchWithNetworkError + #fetchWithNetworkError: WrapPromiseFunctionType #previousFetchStatusStillPending = false - #sse + #sse: EventSource | null - constructor (assembly, rateLimitedQueue) { + status: AssemblyResponse + + pollInterval: ReturnType | null + + closed: boolean + + constructor(assembly: AssemblyResponse, rateLimitedQueue: RateLimitedQueue) { super() // The current assembly status. @@ -47,29 +56,28 @@ class TransloaditAssembly extends Emitter { this.closed = false this.#rateLimitedQueue = rateLimitedQueue - this.#fetchWithNetworkError = rateLimitedQueue.wrapPromiseFunction(fetchWithNetworkError) + this.#fetchWithNetworkError = rateLimitedQueue.wrapPromiseFunction( + fetchWithNetworkError, + ) } - connect () { + connect(): void { this.#connectServerSentEvents() this.#beginPolling() } - #onFinished () { + #onFinished() { this.emit('finished') this.close() } - #connectServerSentEvents () { - this.#sse = new EventSource(`${this.status.websocket_url}?assembly=${this.status.assembly_id}`) + #connectServerSentEvents() { + this.#sse = new EventSource( + `${this.status.websocket_url}?assembly=${this.status.assembly_id}`, + ) this.#sse.addEventListener('open', () => { - // if server side events works, we don't need websockets anymore (it's just a fallback) - if (this.socket) { - this.socket.disconnect() - this.socket = null - } - clearInterval(this.pollInterval) + clearInterval(this.pollInterval!) this.pollInterval = null }) @@ -115,15 +123,18 @@ class TransloaditAssembly extends Emitter { try { this.#onError(JSON.parse(e.data)) } catch { - this.#onError({ msg: e.data }) + this.#onError(new Error(e.data)) } // Refetch for updated status code this.#fetchStatus({ diff: false }) }) } - #onError (status) { - this.emit('error', Object.assign(new Error(status.msg), status)) + #onError(assemblyOrError: AssemblyResponse | NetworkError | Error) { + this.emit( + 'error', + Object.assign(new Error(assemblyOrError.message), assemblyOrError), + ) this.close() } @@ -133,7 +144,7 @@ class TransloaditAssembly extends Emitter { * If the SSE connection fails or takes a long time, we won't miss any * events. */ - #beginPolling () { + #beginPolling() { this.pollInterval = setInterval(() => { this.#fetchStatus() }, 2000) @@ -145,12 +156,19 @@ class TransloaditAssembly extends Emitter { * Pass `diff: false` to avoid emitting diff events, instead only emitting * 'status'. */ - async #fetchStatus ({ diff = true } = {}) { - if (this.closed || this.#rateLimitedQueue.isPaused || this.#previousFetchStatusStillPending) return + async #fetchStatus({ diff = true } = {}) { + if ( + this.closed || + this.#rateLimitedQueue.isPaused || + this.#previousFetchStatusStillPending + ) + return try { this.#previousFetchStatusStillPending = true - const response = await this.#fetchWithNetworkError(this.status.assembly_ssl_url) + const response = await this.#fetchWithNetworkError( + this.status.assembly_ssl_url, + ) this.#previousFetchStatusStillPending = false if (this.closed) return @@ -166,6 +184,7 @@ class TransloaditAssembly extends Emitter { } const status = await response.json() + // Avoid updating if we closed during this request's lifetime. if (this.closed) return this.emit('status', status) @@ -180,17 +199,15 @@ class TransloaditAssembly extends Emitter { } } - update () { + update(): Promise { return this.#fetchStatus({ diff: true }) } /** * Update this assembly's status with a full new object. Events will be * emitted for status changes, new files, and new results. - * - * @param {object} next The new assembly status object. */ - updateStatus (next) { + updateStatus(next: AssemblyResponse): void { this.#diffStatus(this.status, next) this.status = next } @@ -198,11 +215,8 @@ class TransloaditAssembly extends Emitter { /** * Diff two assembly statuses, and emit the events necessary to go from `prev` * to `next`. - * - * @param {object} prev The previous assembly status. - * @param {object} next The new assembly status. */ - #diffStatus (prev, next) { + #diffStatus(prev: AssemblyResponse, next: AssemblyResponse) { const prevStatus = prev.ok const nextStatus = next.ok @@ -219,8 +233,9 @@ class TransloaditAssembly extends Emitter { // The below checks run in this order, that way even if we jump from // UPLOADING straight to FINISHED all the events are emitted as expected. - const nowExecuting = isStatus(nextStatus, ASSEMBLY_EXECUTING) - && !isStatus(prevStatus, ASSEMBLY_EXECUTING) + const nowExecuting = + isStatus(nextStatus, ASSEMBLY_EXECUTING) && + !isStatus(prevStatus, ASSEMBLY_EXECUTING) if (nowExecuting) { // Without SSE, this is our only way to tell if uploading finished. // Hence, we emit this just before the 'upload's and before the 'metadata' @@ -229,10 +244,13 @@ class TransloaditAssembly extends Emitter { this.emit('executing') } - // Find new uploaded files. + // Only emit if the upload is new (not in prev.uploads). Object.keys(next.uploads) .filter((upload) => !has(prev.uploads, upload)) .forEach((upload) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore either the types are wrong or the tests are wrong. + // types think next.uploads is an array, but the tests pass an object. this.emit('upload', next.uploads[upload]) }) @@ -252,8 +270,10 @@ class TransloaditAssembly extends Emitter { }) }) - if (isStatus(nextStatus, ASSEMBLY_COMPLETED) - && !isStatus(prevStatus, ASSEMBLY_COMPLETED)) { + if ( + isStatus(nextStatus, ASSEMBLY_COMPLETED) && + !isStatus(prevStatus, ASSEMBLY_COMPLETED) + ) { this.emit('finished') } @@ -263,13 +283,13 @@ class TransloaditAssembly extends Emitter { /** * Stop updating this assembly. */ - close () { + close(): void { this.closed = true if (this.#sse) { this.#sse.close() this.#sse = null } - clearInterval(this.pollInterval) + clearInterval(this.pollInterval!) this.pollInterval = null } } diff --git a/packages/@uppy/transloadit/src/AssemblyOptions.test.js b/packages/@uppy/transloadit/src/AssemblyOptions.test.js index df75684373b..02f9b495cac 100644 --- a/packages/@uppy/transloadit/src/AssemblyOptions.test.js +++ b/packages/@uppy/transloadit/src/AssemblyOptions.test.js @@ -1,11 +1,9 @@ import { describe, expect, it } from 'vitest' -import AssemblyOptions from './AssemblyOptions.js' +import AssemblyOptions from './AssemblyOptions.ts' describe('Transloadit/AssemblyOptions', () => { it('Validates response from assemblyOptions()', async () => { - const options = new AssemblyOptions([ - { name: 'testfile' }, - ], { + const options = new AssemblyOptions([{ name: 'testfile' }], { assemblyOptions: (file) => { expect(file.name).toBe('testfile') return { @@ -23,21 +21,24 @@ describe('Transloadit/AssemblyOptions', () => { const data = new Uint8Array(10) data.size = data.byteLength - const options = new AssemblyOptions([ - { name: 'a.png', data }, - { name: 'b.png', data }, - { name: 'c.png', data }, - { name: 'd.png', data }, - ], { - assemblyOptions: (file) => ({ - params: { - auth: { key: 'fake key' }, - steps: { - fake_step: { data: file.name }, + const options = new AssemblyOptions( + [ + { name: 'a.png', data }, + { name: 'b.png', data }, + { name: 'c.png', data }, + { name: 'd.png', data }, + ], + { + assemblyOptions: (file) => ({ + params: { + auth: { key: 'fake key' }, + steps: { + fake_step: { data: file.name }, + }, }, - }, - }), - }) + }), + }, + ) const assemblies = await options.build() expect(assemblies).toHaveLength(4) @@ -51,21 +52,24 @@ describe('Transloadit/AssemblyOptions', () => { const data = new Uint8Array(10) const data2 = new Uint8Array(20) - const options = new AssemblyOptions([ - { name: 'a.png', data, size: data.byteLength }, - { name: 'b.png', data, size: data.byteLength }, - { name: 'c.png', data, size: data.byteLength }, - { name: 'd.png', data: data2, size: data2.byteLength }, - ], { - assemblyOptions: (file) => ({ - params: { - auth: { key: 'fake key' }, - steps: { - fake_step: { data: file.size }, + const options = new AssemblyOptions( + [ + { name: 'a.png', data, size: data.byteLength }, + { name: 'b.png', data, size: data.byteLength }, + { name: 'c.png', data, size: data.byteLength }, + { name: 'd.png', data: data2, size: data2.byteLength }, + ], + { + assemblyOptions: (file) => ({ + params: { + auth: { key: 'fake key' }, + steps: { + fake_step: { data: file.size }, + }, }, - }, - }), - }) + }), + }, + ) const assemblies = await options.build() expect(assemblies).toHaveLength(2) @@ -77,7 +81,7 @@ describe('Transloadit/AssemblyOptions', () => { it('Does not create an Assembly if no files are being uploaded', async () => { const options = new AssemblyOptions([], { - assemblyOptions () { + assemblyOptions() { throw new Error('should not create Assembly') }, }) @@ -88,7 +92,7 @@ describe('Transloadit/AssemblyOptions', () => { it('Creates an Assembly if no files are being uploaded but `alwaysRunAssembly` is enabled', async () => { const options = new AssemblyOptions([], { alwaysRunAssembly: true, - async assemblyOptions (file) { + async assemblyOptions(file) { expect(file).toBe(null) return { params: { @@ -103,7 +107,7 @@ describe('Transloadit/AssemblyOptions', () => { }) it('Collects metadata if `fields` is an array', async () => { - function defaultGetAssemblyOptions (file, options) { + function defaultGetAssemblyOptions(file, options) { return { params: options.params, signature: options.signature, @@ -111,19 +115,25 @@ describe('Transloadit/AssemblyOptions', () => { } } - const options = new AssemblyOptions([{ - id: 1, - meta: { watermark: 'Some text' }, - }, { - id: 2, - meta: { watermark: 'ⓒ Transloadit GmbH' }, - }], { - fields: ['watermark'], - params: { - auth: { key: 'fake key' }, + const options = new AssemblyOptions( + [ + { + id: 1, + meta: { watermark: 'Some text' }, + }, + { + id: 2, + meta: { watermark: 'ⓒ Transloadit GmbH' }, + }, + ], + { + fields: ['watermark'], + params: { + auth: { key: 'fake key' }, + }, + assemblyOptions: defaultGetAssemblyOptions, }, - assemblyOptions: defaultGetAssemblyOptions, - }) + ) const assemblies = await options.build() expect(assemblies).toHaveLength(2) diff --git a/packages/@uppy/transloadit/src/AssemblyOptions.js b/packages/@uppy/transloadit/src/AssemblyOptions.ts similarity index 57% rename from packages/@uppy/transloadit/src/AssemblyOptions.js rename to packages/@uppy/transloadit/src/AssemblyOptions.ts index 29d28449757..a4cd9cb98e1 100644 --- a/packages/@uppy/transloadit/src/AssemblyOptions.js +++ b/packages/@uppy/transloadit/src/AssemblyOptions.ts @@ -1,9 +1,11 @@ import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause' +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { AssemblyParameters, Opts, AssemblyOptions as Options } from '.' /** * Check that Assembly parameters are present and include all required fields. */ -function validateParams (params) { +function validateParams(params?: AssemblyParameters | null): void { if (params == null) { throw new Error('Transloadit: The `params` option is required.') } @@ -14,23 +16,41 @@ function validateParams (params) { params = JSON.parse(params) } catch (err) { // Tell the user that this is not an Uppy bug! - throw new ErrorWithCause('Transloadit: The `params` option is a malformed JSON string.', { cause: err }) + throw new ErrorWithCause( + 'Transloadit: The `params` option is a malformed JSON string.', + { cause: err }, + ) } } - if (!params.auth || !params.auth.key) { - throw new Error('Transloadit: The `params.auth.key` option is required. ' - + 'You can find your Transloadit API key at https://transloadit.com/c/template-credentials') + if (!params!.auth || !params!.auth.key) { + throw new Error( + 'Transloadit: The `params.auth.key` option is required. ' + + 'You can find your Transloadit API key at https://transloadit.com/c/template-credentials', + ) } } +export type OptionsWithRestructuredFields = Omit & { + fields: Record +} /** * Combine Assemblies with the same options into a single Assembly for all the * relevant files. */ -function dedupe (list) { - const dedupeMap = Object.create(null) - for (const { fileIDs, options } of list.filter(Boolean)) { +function dedupe( + list: Array< + { fileIDs: string[]; options: OptionsWithRestructuredFields } | undefined + >, +) { + const dedupeMap: Record< + string, + { fileIDArrays: string[][]; options: OptionsWithRestructuredFields } + > = Object.create(null) + for (const { fileIDs, options } of list.filter(Boolean) as Array<{ + fileIDs: string[] + options: OptionsWithRestructuredFields + }>) { const id = JSON.stringify(options) if (id in dedupeMap) { dedupeMap[id].fileIDArrays.push(fileIDs) @@ -48,18 +68,25 @@ function dedupe (list) { })) } -async function getAssemblyOptions (file, options) { - const assemblyOptions = typeof options.assemblyOptions === 'function' - ? await options.assemblyOptions(file, options) - : options.assemblyOptions +async function getAssemblyOptions( + file: UppyFile | null, + options: Opts, +): Promise { + const assemblyOptions = ( + typeof options.assemblyOptions === 'function' ? + await options.assemblyOptions(file, options) + : options.assemblyOptions) as OptionsWithRestructuredFields validateParams(assemblyOptions.params) const { fields } = assemblyOptions if (Array.isArray(fields)) { - assemblyOptions.fields = file == null ? {} : Object.fromEntries( - fields.map((fieldName) => [fieldName, file.meta[fieldName]]), - ) + assemblyOptions.fields = + file == null ? + {} + : Object.fromEntries( + fields.map((fieldName) => [fieldName, file.meta[fieldName]]), + ) } else if (fields == null) { assemblyOptions.fields = {} } @@ -71,8 +98,12 @@ async function getAssemblyOptions (file, options) { * Turn Transloadit plugin options and a list of files into a list of Assembly * options. */ -class AssemblyOptions { - constructor (files, opts) { +class AssemblyOptions { + opts: Opts + + files: UppyFile[] + + constructor(files: UppyFile[], opts: Opts) { this.files = files this.opts = opts } @@ -83,7 +114,9 @@ class AssemblyOptions { * - fileIDs - an array of file IDs to add to this Assembly * - options - Assembly options */ - async build () { + async build(): Promise< + { fileIDs: string[]; options: OptionsWithRestructuredFields }[] + > { const options = this.opts if (this.files.length > 0) { diff --git a/packages/@uppy/transloadit/src/AssemblyWatcher.js b/packages/@uppy/transloadit/src/AssemblyWatcher.ts similarity index 69% rename from packages/@uppy/transloadit/src/AssemblyWatcher.js rename to packages/@uppy/transloadit/src/AssemblyWatcher.ts index bec8bf34c27..075e2fde802 100644 --- a/packages/@uppy/transloadit/src/AssemblyWatcher.js +++ b/packages/@uppy/transloadit/src/AssemblyWatcher.ts @@ -1,4 +1,7 @@ +import type { Uppy } from '@uppy/core' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import Emitter from 'component-emitter' +import type { AssemblyResponse } from '.' /** * Track completion of multiple assemblies. @@ -8,25 +11,30 @@ import Emitter from 'component-emitter' * Exposes a `.promise` property that resolves when all assemblies have * completed (or failed). */ -class TransloaditAssemblyWatcher extends Emitter { +class TransloaditAssemblyWatcher< + M extends Meta, + B extends Body, +> extends Emitter { #assemblyIDs - #reject + #remaining: number - #remaining + promise: Promise - #resolve + #resolve: () => void + + #reject: (reason?: string) => void #uppy - constructor (uppy, assemblyIDs) { + constructor(uppy: Uppy, assemblyIDs: string[]) { super() this.#uppy = uppy this.#assemblyIDs = assemblyIDs this.#remaining = assemblyIDs.length - this.promise = new Promise((resolve, reject) => { + this.promise = new Promise((resolve, reject) => { this.#resolve = resolve this.#reject = reject }) @@ -37,23 +45,25 @@ class TransloaditAssemblyWatcher extends Emitter { /** * Are we watching this assembly ID? */ - #watching (id) { + #watching(id: string) { return this.#assemblyIDs.indexOf(id) !== -1 } - #onAssemblyComplete = (assembly) => { + #onAssemblyComplete = (assembly: AssemblyResponse) => { if (!this.#watching(assembly.assembly_id)) { return } - this.#uppy.log(`[Transloadit] AssemblyWatcher: Got Assembly finish ${assembly.assembly_id}`) + this.#uppy.log( + `[Transloadit] AssemblyWatcher: Got Assembly finish ${assembly.assembly_id}`, + ) this.emit('assembly-complete', assembly.assembly_id) this.#checkAllComplete() } - #onAssemblyCancel = (assembly) => { + #onAssemblyCancel = (assembly: AssemblyResponse) => { if (!this.#watching(assembly.assembly_id)) { return } @@ -61,12 +71,14 @@ class TransloaditAssemblyWatcher extends Emitter { this.#checkAllComplete() } - #onAssemblyError = (assembly, error) => { + #onAssemblyError = (assembly: AssemblyResponse, error: Error) => { if (!this.#watching(assembly.assembly_id)) { return } - this.#uppy.log(`[Transloadit] AssemblyWatcher: Got Assembly error ${assembly.assembly_id}`) + this.#uppy.log( + `[Transloadit] AssemblyWatcher: Got Assembly error ${assembly.assembly_id}`, + ) this.#uppy.log(error) this.emit('assembly-error', assembly.assembly_id, error) @@ -74,7 +86,11 @@ class TransloaditAssemblyWatcher extends Emitter { this.#checkAllComplete() } - #onImportError = (assembly, fileID, error) => { + #onImportError = ( + assembly: AssemblyResponse, + fileID: string, + error: Error, + ) => { if (!this.#watching(assembly.assembly_id)) { return } @@ -87,7 +103,7 @@ class TransloaditAssemblyWatcher extends Emitter { this.#onAssemblyError(assembly, error) } - #checkAllComplete () { + #checkAllComplete() { this.#remaining -= 1 if (this.#remaining === 0) { // We're done, these listeners can be removed @@ -96,14 +112,14 @@ class TransloaditAssemblyWatcher extends Emitter { } } - #removeListeners () { + #removeListeners() { this.#uppy.off('transloadit:complete', this.#onAssemblyComplete) this.#uppy.off('transloadit:assembly-cancel', this.#onAssemblyCancel) this.#uppy.off('transloadit:assembly-error', this.#onAssemblyError) this.#uppy.off('transloadit:import-error', this.#onImportError) } - #addListeners () { + #addListeners() { this.#uppy.on('transloadit:complete', this.#onAssemblyComplete) this.#uppy.on('transloadit:assembly-cancel', this.#onAssemblyCancel) this.#uppy.on('transloadit:assembly-error', this.#onAssemblyError) diff --git a/packages/@uppy/transloadit/src/Client.js b/packages/@uppy/transloadit/src/Client.js deleted file mode 100644 index 34dd954b2d0..00000000000 --- a/packages/@uppy/transloadit/src/Client.js +++ /dev/null @@ -1,213 +0,0 @@ -import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError' - -const ASSEMBLIES_ENDPOINT = '/assemblies' - -/** - * A Barebones HTTP API client for Transloadit. - */ -export default class Client { - #headers = {} - - #fetchWithNetworkError - - constructor (opts = {}) { - this.opts = opts - - if (this.opts.client != null) { - this.#headers['Transloadit-Client'] = this.opts.client - } - - this.#fetchWithNetworkError = this.opts.rateLimitedQueue.wrapPromiseFunction(fetchWithNetworkError) - } - - /** - * @param {[RequestInfo | URL, RequestInit]} args - * @returns {Promise} - */ - #fetchJSON (...args) { - return this.#fetchWithNetworkError(...args).then(response => { - if (response.status === 429) { - this.opts.rateLimitedQueue.rateLimit(2_000) - return this.#fetchJSON(...args) - } - - if (!response.ok) { - const serverError = new Error(response.statusText) - serverError.statusCode = response.status - - if (!`${args[0]}`.endsWith(ASSEMBLIES_ENDPOINT)) return Promise.reject(serverError) - - // Failed assembly requests should return a more detailed error in JSON. - return response.json().then(assembly => { - if (!assembly.error) throw serverError - - const error = new Error(assembly.error) - error.details = assembly.message - error.assembly = assembly - if (assembly.assembly_id) { - error.details += ` Assembly ID: ${assembly.assembly_id}` - } - throw error - }, err => { - // eslint-disable-next-line no-param-reassign - err.cause = serverError - throw err - }) - } - - return response.json() - }) - } - - /** - * Create a new assembly. - * - * @param {object} options - * @param {string|object} options.params - * @param {object} options.fields - * @param {string} options.signature - * @param {number} options.expectedFiles - */ - createAssembly ({ - params, - fields, - signature, - expectedFiles, - }) { - const data = new FormData() - data.append('params', typeof params === 'string' - ? params - : JSON.stringify(params)) - if (signature) { - data.append('signature', signature) - } - - Object.keys(fields).forEach((key) => { - data.append(key, fields[key]) - }) - data.append('num_expected_upload_files', expectedFiles) - - const url = new URL(ASSEMBLIES_ENDPOINT, `${this.opts.service}`).href - return this.#fetchJSON(url, { - method: 'POST', - headers: this.#headers, - body: data, - }) - .catch((err) => this.#reportError(err, { url, type: 'API_ERROR' })) - } - - /** - * Reserve resources for a file in an Assembly. Then addFile can be used later. - * - * @param {object} assembly - * @param {UppyFile} file - */ - reserveFile (assembly, file) { - const size = encodeURIComponent(file.size) - const url = `${assembly.assembly_ssl_url}/reserve_file?size=${size}` - return this.#fetchJSON(url, { method: 'POST', headers: this.#headers }) - .catch((err) => this.#reportError(err, { assembly, file, url, type: 'API_ERROR' })) - } - - /** - * Import a remote file to an Assembly. - * - * @param {object} assembly - * @param {UppyFile} file - */ - addFile (assembly, file) { - if (!file.uploadURL) { - return Promise.reject(new Error('File does not have an `uploadURL`.')) - } - const size = encodeURIComponent(file.size) - const uploadUrl = encodeURIComponent(file.uploadURL) - const filename = encodeURIComponent(file.name) - const fieldname = 'file' - - const qs = `size=${size}&filename=${filename}&fieldname=${fieldname}&s3Url=${uploadUrl}` - const url = `${assembly.assembly_ssl_url}/add_file?${qs}` - return this.#fetchJSON(url, { method: 'POST', headers: this.#headers }) - .catch((err) => this.#reportError(err, { assembly, file, url, type: 'API_ERROR' })) - } - - /** - * Update the number of expected files in an already created assembly. - * - * @param {object} assembly - * @param {number} num_expected_upload_files - */ - updateNumberOfFilesInAssembly (assembly, num_expected_upload_files) { - const url = new URL(assembly.assembly_ssl_url) - url.pathname = '/update_assemblies' - const body = JSON.stringify({ - assembly_updates: [{ - assembly_id: assembly.assembly_id, - num_expected_upload_files, - }], - }) - return this.#fetchJSON(url, { method: 'POST', headers: this.#headers, body }) - .catch((err) => this.#reportError(err, { url, type: 'API_ERROR' })) - } - - /** - * Cancel a running Assembly. - * - * @param {object} assembly - */ - cancelAssembly (assembly) { - const url = assembly.assembly_ssl_url - return this.#fetchJSON(url, { method: 'DELETE', headers: this.#headers }) - .catch((err) => this.#reportError(err, { url, type: 'API_ERROR' })) - } - - /** - * Get the current status for an assembly. - * - * @param {string} url The status endpoint of the assembly. - */ - getAssemblyStatus (url) { - return this.#fetchJSON(url, { headers: this.#headers }) - .catch((err) => this.#reportError(err, { url, type: 'STATUS_ERROR' })) - } - - submitError (err, { endpoint, instance, assembly } = {}) { - const message = err.details - ? `${err.message} (${err.details})` - : err.message - - return this.#fetchJSON('https://transloaditstatus.com/client_error', { - method: 'POST', - body: JSON.stringify({ - endpoint, - instance, - assembly_id: assembly, - agent: typeof navigator !== 'undefined' ? navigator.userAgent : '', - client: this.opts.client, - error: message, - }), - }) - } - - #reportError = (err, params) => { - if (this.opts.errorReporting === false) { - throw err - } - - const opts = { - type: params.type, - } - if (params.assembly) { - opts.assembly = params.assembly.assembly_id - opts.instance = params.assembly.instance - } - if (params.url) { - opts.endpoint = params.url - } - - this.submitError(err, opts).catch(() => { - // not much we can do then is there - }) - - throw err - } -} diff --git a/packages/@uppy/transloadit/src/Client.ts b/packages/@uppy/transloadit/src/Client.ts new file mode 100644 index 00000000000..da8a4c32e75 --- /dev/null +++ b/packages/@uppy/transloadit/src/Client.ts @@ -0,0 +1,280 @@ +import type { + RateLimitedQueue, + WrapPromiseFunctionType, +} from '@uppy/utils/lib/RateLimitedQueue' +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError' +import type { AssemblyResponse } from '.' +import type { OptionsWithRestructuredFields } from './AssemblyOptions' + +const ASSEMBLIES_ENDPOINT = '/assemblies' + +type Opts = { + client?: string + service: string + rateLimitedQueue: RateLimitedQueue + errorReporting: boolean +} + +export class AssemblyError extends Error { + details: string | undefined + + assembly: AssemblyResponse + + constructor( + message: string, + details: string | undefined, + assembly: AssemblyResponse, + ) { + super(message) + this.details = details + this.assembly = assembly + } +} + +/** + * A Barebones HTTP API client for Transloadit. + */ +export default class Client { + #headers: Record = {} + + #fetchWithNetworkError: WrapPromiseFunctionType + + opts: Opts + + constructor(opts: Opts) { + this.opts = opts + + if (this.opts.client != null) { + this.#headers['Transloadit-Client'] = this.opts.client + } + + this.#fetchWithNetworkError = + this.opts.rateLimitedQueue.wrapPromiseFunction(fetchWithNetworkError) + } + + async #fetchJSON( + ...args: Parameters + ): Promise { + const response = await this.#fetchWithNetworkError(...args) + + if (response.status === 429) { + this.opts.rateLimitedQueue.rateLimit(2_000) + return this.#fetchJSON(...args) + } + + if (!response.ok) { + const serverError = new Error(response.statusText) + // @ts-expect-error statusCode is not a standard property + serverError.statusCode = response.status + + if (!`${args[0]}`.endsWith(ASSEMBLIES_ENDPOINT)) + return Promise.reject(serverError) + + // Failed assembly requests should return a more detailed error in JSON. + return response.json().then( + (assembly: AssemblyResponse) => { + if (!assembly.error) throw serverError + + const error = new AssemblyError( + assembly.error, + assembly.message, + assembly, + ) + + if (assembly.assembly_id) { + error.details += ` Assembly ID: ${assembly.assembly_id}` + } + throw error + }, + (err) => { + // eslint-disable-next-line no-param-reassign + err.cause = serverError + throw err + }, + ) + } + + return response.json() + } + + async createAssembly({ + params, + fields, + signature, + expectedFiles, + }: OptionsWithRestructuredFields & { + expectedFiles: number + }): Promise { + const data = new FormData() + data.append( + 'params', + typeof params === 'string' ? params : JSON.stringify(params), + ) + if (signature) { + data.append('signature', signature) + } + + Object.keys(fields).forEach((key) => { + data.append(key, String(fields[key])) + }) + data.append('num_expected_upload_files', String(expectedFiles)) + + const url = new URL(ASSEMBLIES_ENDPOINT, `${this.opts.service}`).href + return this.#fetchJSON(url, { + method: 'POST', + headers: this.#headers, + body: data, + }).catch((err) => this.#reportError(err, { url, type: 'API_ERROR' })) + } + + /** + * Reserve resources for a file in an Assembly. Then addFile can be used later. + */ + async reserveFile( + assembly: AssemblyResponse, + file: UppyFile, + ): Promise { + const size = encodeURIComponent(file.size!) + const url = `${assembly.assembly_ssl_url}/reserve_file?size=${size}` + return this.#fetchJSON(url, { + method: 'POST', + headers: this.#headers, + }).catch((err) => + this.#reportError(err, { assembly, file, url, type: 'API_ERROR' }), + ) + } + + /** + * Import a remote file to an Assembly. + */ + async addFile( + assembly: AssemblyResponse, + file: UppyFile, + ): Promise { + if (!file.uploadURL) { + return Promise.reject(new Error('File does not have an `uploadURL`.')) + } + const size = encodeURIComponent(file.size!) + const uploadUrl = encodeURIComponent(file.uploadURL) + const filename = encodeURIComponent(file.name) + const fieldname = 'file' + + const qs = `size=${size}&filename=${filename}&fieldname=${fieldname}&s3Url=${uploadUrl}` + const url = `${assembly.assembly_ssl_url}/add_file?${qs}` + return this.#fetchJSON(url, { + method: 'POST', + headers: this.#headers, + }).catch((err) => + this.#reportError(err, { assembly, file, url, type: 'API_ERROR' }), + ) + } + + /** + * Update the number of expected files in an already created assembly. + */ + async updateNumberOfFilesInAssembly( + assembly: AssemblyResponse, + num_expected_upload_files: number, + ): Promise { + const url = new URL(assembly.assembly_ssl_url) + url.pathname = '/update_assemblies' + const body = JSON.stringify({ + assembly_updates: [ + { + assembly_id: assembly.assembly_id, + num_expected_upload_files, + }, + ], + }) + return this.#fetchJSON(url, { + method: 'POST', + headers: this.#headers, + body, + }).catch((err) => this.#reportError(err, { url, type: 'API_ERROR' })) + } + + /** + * Cancel a running Assembly. + */ + async cancelAssembly(assembly: AssemblyResponse): Promise { + const url = assembly.assembly_ssl_url + return this.#fetchJSON(url, { + method: 'DELETE', + headers: this.#headers, + }).catch((err) => this.#reportError(err, { url, type: 'API_ERROR' })) + } + + /** + * Get the current status for an assembly. + */ + async getAssemblyStatus(url: string): Promise { + return this.#fetchJSON(url, { headers: this.#headers }).catch((err) => + this.#reportError(err, { url, type: 'STATUS_ERROR' }), + ) + } + + async submitError( + err: { message?: string; details?: string }, + { + endpoint, + instance, + assembly, + }: { + endpoint?: string | URL + instance?: string + assembly?: string + } = {}, + ): Promise { + const message = + err.details ? `${err.message} (${err.details})` : err.message + + return this.#fetchJSON('https://transloaditstatus.com/client_error', { + method: 'POST', + body: JSON.stringify({ + endpoint, + instance, + assembly_id: assembly, + agent: typeof navigator !== 'undefined' ? navigator.userAgent : '', + client: this.opts.client, + error: message, + }), + }) + } + + #reportError = ( + err: AssemblyError, + params: { + assembly?: AssemblyResponse + url?: URL | string + file?: UppyFile + type: string + }, + ) => { + if (this.opts.errorReporting === false) { + throw err + } + + const opts: { + type: string + assembly?: string + instance?: string + endpoint?: URL | string + } = { + type: params.type, + } + if (params.assembly) { + opts.assembly = params.assembly.assembly_id + opts.instance = params.assembly.instance + } + if (params.url) { + opts.endpoint = params.url + } + + this.submitError(err, opts).catch(() => { + // not much we can do then is there + }) + + throw err + } +} diff --git a/packages/@uppy/transloadit/src/index.test.js b/packages/@uppy/transloadit/src/index.test.js index 716ad932671..348cfe89096 100644 --- a/packages/@uppy/transloadit/src/index.test.js +++ b/packages/@uppy/transloadit/src/index.test.js @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import { once } from 'node:events' import { describe, expect, it } from 'vitest' import Core from '@uppy/core' -import Transloadit from './index.js' +import Transloadit from './index.ts' import 'whatwg-fetch' describe('Transloadit', () => { @@ -30,7 +30,8 @@ describe('Transloadit', () => { }).toThrowError(/The `params\.auth\.key` option is required/) expect(() => { uppy.use(Transloadit, { - params: '{"auth":{"key":"some auth key string"},"template_id":"some template id string"}', + params: + '{"auth":{"key":"some auth key string"},"template_id":"some template id string"}', }) }).not.toThrowError(/The `params\.auth\.key` option is required/) }) @@ -39,7 +40,7 @@ describe('Transloadit', () => { const error = new Error('expected failure') const uppy = new Core() uppy.use(Transloadit, { - getAssemblyOptions () { + getAssemblyOptions() { return Promise.reject(error) }, }) @@ -50,14 +51,17 @@ describe('Transloadit', () => { data: new Uint8Array(100), }) - return uppy.upload().then(() => { - throw new Error('Should not have succeeded') - }).catch((err) => { - const fileID = Object.keys(uppy.getState().files)[0] + return uppy + .upload() + .then(() => { + throw new Error('Should not have succeeded') + }) + .catch((err) => { + const fileID = Object.keys(uppy.getState().files)[0] - expect(err).toBe(error) - expect(uppy.getFile(fileID).progress.uploadStarted).toBe(null) - }) + expect(err).toBe(error) + expect(uppy.getFile(fileID).progress.uploadStarted).toBe(null) + }) }) it('Does not leave lingering progress if creating assembly fails', () => { @@ -69,7 +73,8 @@ describe('Transloadit', () => { }, }) - uppy.getPlugin('Transloadit').client.createAssembly = () => Promise.reject(new Error('VIDEO_ENCODE_VALIDATION')) + uppy.getPlugin('Transloadit').client.createAssembly = () => + Promise.reject(new Error('VIDEO_ENCODE_VALIDATION')) uppy.addFile({ source: 'jest', @@ -77,14 +82,19 @@ describe('Transloadit', () => { data: new Uint8Array(100), }) - return uppy.upload().then(() => { - throw new Error('Should not have succeeded') - }, (err) => { - const fileID = Object.keys(uppy.getState().files)[0] + return uppy.upload().then( + () => { + throw new Error('Should not have succeeded') + }, + (err) => { + const fileID = Object.keys(uppy.getState().files)[0] - expect(err.message).toBe('Transloadit: Could not create Assembly: VIDEO_ENCODE_VALIDATION') - expect(uppy.getFile(fileID).progress.uploadStarted).toBe(null) - }) + expect(err.message).toBe( + 'Transloadit: Could not create Assembly: VIDEO_ENCODE_VALIDATION', + ) + expect(uppy.getFile(fileID).progress.uploadStarted).toBe(null) + }, + ) }) // For some reason this test doesn't pass on CI @@ -110,7 +120,7 @@ describe('Transloadit', () => { await uppy.upload() server.closeAllConnections() - await new Promise(resolve => server.close(resolve)) + await new Promise((resolve) => server.close(resolve)) }) // For some reason this test doesn't pass on CI @@ -137,6 +147,6 @@ describe('Transloadit', () => { await uppy.upload() server.closeAllConnections() - await new Promise(resolve => server.close(resolve)) + await new Promise((resolve) => server.close(resolve)) }) }) diff --git a/packages/@uppy/transloadit/src/index.js b/packages/@uppy/transloadit/src/index.ts similarity index 50% rename from packages/@uppy/transloadit/src/index.js rename to packages/@uppy/transloadit/src/index.ts index 0842e96084e..aab1df33fe5 100644 --- a/packages/@uppy/transloadit/src/index.js +++ b/packages/@uppy/transloadit/src/index.ts @@ -2,17 +2,111 @@ import hasProperty from '@uppy/utils/lib/hasProperty' import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause' import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue' import BasePlugin from '@uppy/core/lib/BasePlugin.js' -import Tus from '@uppy/tus' -import Assembly from './Assembly.js' -import Client from './Client.js' -import AssemblyOptions, { validateParams } from './AssemblyOptions.js' -import AssemblyWatcher from './AssemblyWatcher.js' - -import locale from './locale.js' +import type { DefinePluginOpts, PluginOpts } from '@uppy/core/lib/BasePlugin.js' +import Tus, { type TusDetailedError } from '@uppy/tus' +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { Uppy } from '@uppy/core' +import Assembly from './Assembly.ts' +import Client, { AssemblyError } from './Client.ts' +import AssemblyOptionsBuilder, { + validateParams, + type OptionsWithRestructuredFields, +} from './AssemblyOptions.ts' +import AssemblyWatcher from './AssemblyWatcher.ts' + +import locale from './locale.ts' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' -const sendErrorToConsole = originalErr => err => { - const error = new ErrorWithCause('Failed to send error to the client', { cause: err }) +export interface AssemblyFile { + id: string + name: string + basename: string + ext: string + size: number + mime: string + type: string + field: string + md5hash: string + is_tus_file: boolean + original_md5hash: string + original_id: string + original_name: string + original_basename: string + original_path: string + url: string + ssl_url: string + tus_upload_url: string + meta: Record +} + +export interface AssemblyResult extends AssemblyFile { + cost: number + execTime: number + queue: string + queueTime: number + localId: string | null +} + +export interface AssemblyResponse { + ok: string + message?: string + assembly_id: string + parent_id?: string + account_id: string + template_id?: string + instance: string + assembly_url: string + assembly_ssl_url: string + uppyserver_url: string + companion_url: string + websocket_url: string + tus_url: string + bytes_received: number + bytes_expected: number + upload_duration: number + client_agent?: string + client_ip?: string + client_referer?: string + transloadit_client: string + start_date: string + upload_meta_data_extracted: boolean + warnings: any[] + is_infinite: boolean + has_dupe_jobs: boolean + execution_start: string + execution_duration: number + queue_duration: number + jobs_queue_duration: number + notify_start?: any + notify_url?: string + notify_status?: any + notify_response_code?: any + notify_duration?: any + last_job_completed?: string + fields: Record + running_jobs: any[] + bytes_usage: number + executing_jobs: any[] + started_jobs: string[] + parent_assembly_status: any + params: string + template?: any + merged_params: string + uploads: AssemblyFile[] + results: Record + build_id: string + error?: string + stderr?: string + stdout?: string + reason?: string +} + +const sendErrorToConsole = (originalErr: Error) => (err: Error) => { + const error = new ErrorWithCause('Failed to send error to the client', { + cause: err, + }) // eslint-disable-next-line no-console console.error(error, originalErr) } @@ -23,10 +117,179 @@ const COMPANION_ALLOWED_HOSTS = /\.transloadit\.com$/ // Regex used to check if a Companion address is run by Transloadit. const TL_COMPANION = /https?:\/\/api2(?:-\w+)?\.transloadit\.com\/companion/ +export interface AssemblyParameters { + auth: { + key: string + expires?: string + } + template_id?: string + steps?: { [step: string]: Record } + fields?: { [name: string]: number | string } + notify_url?: string +} + +export interface AssemblyOptions { + params?: AssemblyParameters | null + fields?: Record | string[] | null + signature?: string | null +} + +interface BaseOptions extends PluginOpts { + service?: string + errorReporting?: boolean + waitForEncoding?: boolean + waitForMetadata?: boolean + importFromUploadURLs?: boolean + alwaysRunAssembly?: boolean + limit?: number + clientName?: string | null + retryDelays?: number[] +} + +export type TransloaditOptions = BaseOptions & + ( + | { + assemblyOptions?: + | AssemblyOptions + | (( + file?: UppyFile | null, + options?: AssemblyOptions, + ) => Promise | AssemblyOptions) + /** @deprecated use `assemblyOptions` instead */ + getAssemblyOptions?: never | null + /** @deprecated use `assemblyOptions` instead */ + params?: never | null + /** @deprecated use `assemblyOptions` instead */ + fields?: never | null + /** @deprecated use `assemblyOptions` instead */ + signature?: never | null + } + | { + /** @deprecated use `assemblyOptions` instead */ + getAssemblyOptions?: ( + file?: UppyFile | null, + ) => AssemblyOptions | Promise + assemblyOptions?: never + /** @deprecated use `assemblyOptions` instead */ + params?: never | null + /** @deprecated use `assemblyOptions` instead */ + fields?: never | null + /** @deprecated use `assemblyOptions` instead */ + signature?: never | null + } + | { + /** @deprecated use `assemblyOptions` instead */ + params?: AssemblyParameters | null + /** @deprecated use `assemblyOptions` instead */ + fields?: { [name: string]: number | string } | string[] | null + /** @deprecated use `assemblyOptions` instead */ + signature?: string | null + /** @deprecated use `assemblyOptions` instead */ + getAssemblyOptions?: never | null + assemblyOptions?: never + } + ) + +const defaultOptions = { + service: 'https://api2.transloadit.com', + errorReporting: true, + waitForEncoding: false, + waitForMetadata: false, + alwaysRunAssembly: false, + importFromUploadURLs: false, + /** @deprecated use `assemblyOptions` instead */ + signature: null, + /** @deprecated use `assemblyOptions` instead */ + params: null, + /** @deprecated use `assemblyOptions` instead */ + fields: null, + /** @deprecated use `assemblyOptions` instead */ + getAssemblyOptions: null, + limit: 20, + retryDelays: [7_000, 10_000, 15_000, 20_000], + clientName: null, +} satisfies TransloaditOptions + +export type Opts = DefinePluginOpts< + TransloaditOptions, + keyof typeof defaultOptions +> + +type TransloaditState = { + assemblies: Record + files: Record< + string, + { assembly: string; id: string; uploadedFile: AssemblyFile } + > + results: Array<{ + result: AssemblyResult + stepName: string + id: string + assembly: string + }> + uploadsAssemblies: Record +} + +declare module '@uppy/core' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface UppyEventMap { + // We're also overriding the `restored` event as it is now populated with Transloadit state. + restored: (pluginData: Record) => void + 'restore:get-data': ( + setData: ( + arg: Record< + string, + Pick + >, + ) => void, + ) => void + 'transloadit:assembly-created': ( + assembly: AssemblyResponse, + fileIDs: string[], + ) => void + 'transloadit:assembly-cancel': (assembly: AssemblyResponse) => void + 'transloadit:import-error': ( + assembly: AssemblyResponse, + fileID: string, + error: Error, + ) => void + 'transloadit:assembly-error': ( + assembly: AssemblyResponse, + error: Error, + ) => void + 'transloadit:assembly-executing': (assembly: AssemblyResponse) => void + 'transloadit:assembly-cancelled': (assembly: AssemblyResponse) => void + 'transloadit:upload': ( + file: AssemblyFile, + assembly: AssemblyResponse, + ) => void + 'transloadit:result': ( + stepName: string, + result: AssemblyResult, + assembly: AssemblyResponse, + ) => void + 'transloadit:complete': (assembly: AssemblyResponse) => void + 'transloadit:execution-progress': (details: { + progress_combined?: number + }) => void + } +} + +declare module '@uppy/utils/lib/UppyFile' { + // eslint-disable-next-line no-shadow, @typescript-eslint/no-unused-vars + export interface UppyFile { + transloadit?: { assembly: string } + tus?: { uploadUrl?: string | null } + } +} + /** * Upload files to Transloadit using Tus. */ -export default class Transloadit extends BasePlugin { +export default class Transloadit< + M extends Meta, + B extends Body, +> extends BasePlugin, M, B, TransloaditState> { static VERSION = packageJson.version /** @deprecated use `import { COMPANION_URL } from '@uppy/transloadit'` instead. */ @@ -35,38 +298,25 @@ export default class Transloadit extends BasePlugin { /** @deprecated use `import { COMPANION_ALLOWED_HOSTS } from '@uppy/transloadit'` instead. */ static COMPANION_PATTERN = COMPANION_ALLOWED_HOSTS - #rateLimitedQueue + #rateLimitedQueue: RateLimitedQueue + + client: Client + + activeAssemblies: Record + + assemblyWatchers: Record> - constructor (uppy, opts) { - super(uppy, opts) + completedFiles: Record + + restored: Promise | null + + constructor(uppy: Uppy, opts: TransloaditOptions) { + super(uppy, { ...defaultOptions, ...opts }) this.type = 'uploader' this.id = this.opts.id || 'Transloadit' - this.title = 'Transloadit' this.defaultLocale = locale - const defaultOptions = { - service: 'https://api2.transloadit.com', - errorReporting: true, - waitForEncoding: false, - waitForMetadata: false, - alwaysRunAssembly: false, - importFromUploadURLs: false, - /** @deprecated use `assemblyOptions` instead */ - signature: null, - /** @deprecated use `assemblyOptions` instead */ - params: null, - /** @deprecated use `assemblyOptions` instead */ - fields: null, - /** @deprecated use `assemblyOptions` instead */ - getAssemblyOptions: null, - limit: 20, - retryDelays: [7_000, 10_000, 15_000, 20_000], - clientName: null, - } - - this.opts = { ...defaultOptions, ...opts } - // TODO: remove this fallback in the next major this.opts.assemblyOptions ??= this.opts.getAssemblyOptions ?? { params: this.opts.params, @@ -75,8 +325,12 @@ export default class Transloadit extends BasePlugin { } // TODO: remove this check in the next major (validating params when creating the assembly should be enough) - if (opts?.params != null && opts.getAssemblyOptions == null && opts.assemblyOptions == null) { - validateParams(this.opts.assemblyOptions.params) + if ( + opts?.params != null && + opts.getAssemblyOptions == null && + opts.assemblyOptions == null + ) { + validateParams((this.opts.assemblyOptions as AssemblyOptions).params) } this.#rateLimitedQueue = new RateLimitedQueue(this.opts.limit) @@ -98,16 +352,19 @@ export default class Transloadit extends BasePlugin { this.completedFiles = Object.create(null) } - #getClientVersion () { + #getClientVersion() { const list = [ + // @ts-expect-error VERSION comes from babel, TS does not understand `uppy-core:${this.uppy.constructor.VERSION}`, + // @ts-expect-error VERSION comes from babel, TS does not understand `uppy-transloadit:${this.constructor.VERSION}`, `uppy-tus:${Tus.VERSION}`, ] - const addPluginVersion = (pluginName, versionName) => { + const addPluginVersion = (pluginName: string, versionName: string) => { const plugin = this.uppy.getPlugin(pluginName) if (plugin) { + // @ts-expect-error VERSION comes from babel, TS does not understand list.push(`${versionName}:${plugin.constructor.VERSION}`) } } @@ -139,11 +396,8 @@ export default class Transloadit extends BasePlugin { * Also use Transloadit's Companion * * See: https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus - * - * @param {object} file - * @param {object} status */ - #attachAssemblyMetadata (file, status) { + #attachAssemblyMetadata(file: UppyFile, status: AssemblyResponse) { // Add the metadata parameters Transloadit needs. const meta = { ...file.meta, @@ -166,8 +420,7 @@ export default class Transloadit extends BasePlugin { let { remote } = file if (file.remote && TL_COMPANION.test(file.remote.companionUrl)) { - const newHost = status.companion_url - .replace(/\/$/, '') + const newHost = status.companion_url.replace(/\/$/, '') const path = file.remote.url .replace(file.remote.companionUrl, '') .replace(/^\//, '') @@ -193,106 +446,131 @@ export default class Transloadit extends BasePlugin { return newFile } - #createAssembly (fileIDs, uploadID, assemblyOptions) { + #createAssembly( + fileIDs: string[], + uploadID: string, + assemblyOptions: OptionsWithRestructuredFields, + ) { this.uppy.log('[Transloadit] Create Assembly') - return this.client.createAssembly({ - ...assemblyOptions, - expectedFiles: fileIDs.length, - }).then(async (newAssembly) => { - const files = this.uppy.getFiles().filter(({ id }) => fileIDs.includes(id)) - if (files.length !== fileIDs.length) { - if (files.length === 0) { - // All files have been removed, cancelling. - await this.client.cancelAssembly(newAssembly) - return null + return this.client + .createAssembly({ + ...assemblyOptions, + expectedFiles: fileIDs.length, + }) + .then(async (newAssembly) => { + const files = this.uppy + .getFiles() + .filter(({ id }) => fileIDs.includes(id)) + if (files.length !== fileIDs.length) { + if (files.length === 0) { + // All files have been removed, cancelling. + await this.client.cancelAssembly(newAssembly) + return null + } + // At least one file has been removed. + await this.client.updateNumberOfFilesInAssembly( + newAssembly, + files.length, + ) } - // At least one file has been removed. - await this.client.updateNumberOfFilesInAssembly(newAssembly, files.length) - } - - const assembly = new Assembly(newAssembly, this.#rateLimitedQueue) - const { status } = assembly - const assemblyID = status.assembly_id - const { assemblies, uploadsAssemblies } = this.getPluginState() - this.setPluginState({ - // Store the Assembly status. - assemblies: { - ...assemblies, - [assemblyID]: status, - }, - // Store the list of Assemblies related to this upload. - uploadsAssemblies: { - ...uploadsAssemblies, - [uploadID]: [ - ...uploadsAssemblies[uploadID], - assemblyID, - ], - }, - }) + const assembly = new Assembly(newAssembly, this.#rateLimitedQueue) + const { status } = assembly + const assemblyID = status.assembly_id + + const { assemblies, uploadsAssemblies } = this.getPluginState() + this.setPluginState({ + // Store the Assembly status. + assemblies: { + ...assemblies, + [assemblyID]: status, + }, + // Store the list of Assemblies related to this upload. + uploadsAssemblies: { + ...uploadsAssemblies, + [uploadID]: [...uploadsAssemblies[uploadID], assemblyID], + }, + }) - const updatedFiles = {} - files.forEach((file) => { - updatedFiles[file.id] = this.#attachAssemblyMetadata(file, status) - }) + const updatedFiles: Record> = {} + files.forEach((file) => { + updatedFiles[file.id] = this.#attachAssemblyMetadata(file, status) + }) - this.uppy.setState({ - files: { - ...this.uppy.getState().files, - ...updatedFiles, - }, - }) + this.uppy.setState({ + files: { + ...this.uppy.getState().files, + ...updatedFiles, + }, + }) - // TODO: this should not live inside a `file-removed` event but somewhere more deterministic. - // Such as inside the function where the assembly has succeeded or cancelled. - // For the use case of cancelling the assembly when needed, we should try to do that with just `cancel-all`. - const fileRemovedHandler = (fileRemoved, reason) => { - // If the assembly has successfully completed, we do not need these checks. - // Otherwise we may cancel an assembly after it already succeeded - if (assembly.status?.ok === 'ASSEMBLY_COMPLETED') { - this.uppy.off('file-removed', fileRemovedHandler) - return - } - if (reason === 'cancel-all') { - assembly.close() - this.uppy.off('file-removed', fileRemovedHandler) - } else if (fileRemoved.id in updatedFiles) { - delete updatedFiles[fileRemoved.id] - const nbOfRemainingFiles = Object.keys(updatedFiles).length - if (nbOfRemainingFiles === 0) { + // TODO: this should not live inside a `file-removed` event but somewhere more deterministic. + // Such as inside the function where the assembly has succeeded or cancelled. + // For the use case of cancelling the assembly when needed, we should try to do that with just `cancel-all`. + const fileRemovedHandler = ( + fileRemoved: UppyFile, + reason?: string, + ) => { + // If the assembly has successfully completed, we do not need these checks. + // Otherwise we may cancel an assembly after it already succeeded + if (assembly.status?.ok === 'ASSEMBLY_COMPLETED') { + this.uppy.off('file-removed', fileRemovedHandler) + return + } + if (reason === 'cancel-all') { assembly.close() - this.#cancelAssembly(newAssembly).catch(() => { /* ignore potential errors */ }) this.uppy.off('file-removed', fileRemovedHandler) - } else { - this.client.updateNumberOfFilesInAssembly(newAssembly, nbOfRemainingFiles) - .catch(() => { /* ignore potential errors */ }) + } else if (fileRemoved.id in updatedFiles) { + delete updatedFiles[fileRemoved.id] + const nbOfRemainingFiles = Object.keys(updatedFiles).length + if (nbOfRemainingFiles === 0) { + assembly.close() + this.#cancelAssembly(newAssembly).catch(() => { + /* ignore potential errors */ + }) + this.uppy.off('file-removed', fileRemovedHandler) + } else { + this.client + .updateNumberOfFilesInAssembly(newAssembly, nbOfRemainingFiles) + .catch(() => { + /* ignore potential errors */ + }) + } } } - } - this.uppy.on('file-removed', fileRemovedHandler) + this.uppy.on('file-removed', fileRemovedHandler) - this.uppy.emit('transloadit:assembly-created', status, fileIDs) + this.uppy.emit('transloadit:assembly-created', status, fileIDs) - this.uppy.log(`[Transloadit] Created Assembly ${assemblyID}`) - return assembly - }).catch((err) => { - const wrapped = new ErrorWithCause(`${this.i18n('creatingAssemblyFailed')}: ${err.message}`, { cause: err }) - if ('details' in err) { - wrapped.details = err.details - } - if ('assembly' in err) { - wrapped.assembly = err.assembly - } - throw wrapped - }) + this.uppy.log(`[Transloadit] Created Assembly ${assemblyID}`) + return assembly + }) + .catch((err) => { + // TODO: use AssemblyError? + const wrapped = new ErrorWithCause( + `${this.i18n('creatingAssemblyFailed')}: ${err.message}`, + { cause: err }, + ) + if ('details' in err) { + // @ts-expect-error details is not in the Error type + wrapped.details = err.details + } + if ('assembly' in err) { + // @ts-expect-error assembly is not in the Error type + wrapped.assembly = err.assembly + } + throw wrapped + }) } - #createAssemblyWatcher (assemblyID, uploadID) { - // AssemblyWatcher tracks completion states of all Assemblies in this upload. - const watcher = new AssemblyWatcher(this.uppy, assemblyID) + #createAssemblyWatcher(idOrArrayOfIds: string | string[], uploadID: string) { + // AssemblyWatcher tracks completion states of all Assemblies in this upload. + const ids = + Array.isArray(idOrArrayOfIds) ? idOrArrayOfIds : [idOrArrayOfIds] + const watcher = new AssemblyWatcher(this.uppy, ids) - watcher.on('assembly-complete', (id) => { + watcher.on('assembly-complete', (id: string) => { const files = this.getAssemblyFiles(id) files.forEach((file) => { this.completedFiles[file.id] = true @@ -300,7 +578,7 @@ export default class Transloadit extends BasePlugin { }) }) - watcher.on('assembly-error', (id, error) => { + watcher.on('assembly-error', (id: string, error: Error) => { // Clear postprocessing state for all our files. const filesFromAssembly = this.getAssemblyFiles(id) filesFromAssembly.forEach((file) => { @@ -315,7 +593,7 @@ export default class Transloadit extends BasePlugin { // re-use the old one. See: https://github.com/transloadit/uppy/issues/4412 // and `onReceiveUploadUrl` in @uppy/tus const files = { ...this.uppy.getState().files } - filesFromAssembly.forEach(file => delete files[file.id].tus) + filesFromAssembly.forEach((file) => delete files[file.id].tus) this.uppy.setState({ files }) this.uppy.emit('error', error) @@ -324,7 +602,7 @@ export default class Transloadit extends BasePlugin { this.assemblyWatchers[uploadID] = watcher } - #shouldWaitAfterUpload () { + #shouldWaitAfterUpload() { return this.opts.waitForEncoding || this.opts.waitForMetadata } @@ -332,19 +610,21 @@ export default class Transloadit extends BasePlugin { * Used when `importFromUploadURLs` is enabled: reserves all files in * the Assembly. */ - #reserveFiles (assembly, fileIDs) { - return Promise.all(fileIDs.map((fileID) => { - const file = this.uppy.getFile(fileID) - return this.client.reserveFile(assembly.status, file) - })) + #reserveFiles(assembly: Assembly, fileIDs: string[]) { + return Promise.all( + fileIDs.map((fileID) => { + const file = this.uppy.getFile(fileID) + return this.client.reserveFile(assembly.status, file) + }), + ) } /** * Used when `importFromUploadURLs` is enabled: adds files to the Assembly * once they have been fully uploaded. */ - #onFileUploadURLAvailable = (rawFile) => { - const file = this.uppy.getFile(rawFile.id) + #onFileUploadURLAvailable = (rawFile: UppyFile | undefined) => { + const file = this.uppy.getFile(rawFile!.id) if (!file?.transloadit?.assembly) { return } @@ -358,7 +638,7 @@ export default class Transloadit extends BasePlugin { }) } - #findFile (uploadedFile) { + #findFile(uploadedFile: AssemblyFile) { const files = this.uppy.getFiles() for (let i = 0; i < files.length; i++) { const file = files[i] @@ -372,7 +652,10 @@ export default class Transloadit extends BasePlugin { } if (!uploadedFile.is_tus_file) { // Fingers-crossed check for non-tus uploads, eg imported from S3. - if (file.name === uploadedFile.name && file.size === uploadedFile.size) { + if ( + file.name === uploadedFile.name && + file.size === uploadedFile.size + ) { return file } } @@ -380,11 +663,13 @@ export default class Transloadit extends BasePlugin { return undefined } - #onFileUploadComplete (assemblyId, uploadedFile) { + #onFileUploadComplete(assemblyId: string, uploadedFile: AssemblyFile) { const state = this.getPluginState() const file = this.#findFile(uploadedFile) if (!file) { - this.uppy.log('[Transloadit] Couldn’t find the file, it was likely removed in the process') + this.uppy.log( + '[Transloadit] Couldn’t find the file, it was likely removed in the process', + ) return } this.setPluginState({ @@ -397,17 +682,14 @@ export default class Transloadit extends BasePlugin { }, }, }) - this.uppy.emit('transloadit:upload', uploadedFile, this.getAssembly(assemblyId)) + this.uppy.emit( + 'transloadit:upload', + uploadedFile, + this.getAssembly(assemblyId), + ) } - /** - * Callback when a new Assembly result comes in. - * - * @param {string} assemblyId - * @param {string} stepName - * @param {object} result - */ - #onResult (assemblyId, stepName, result) { + #onResult(assemblyId: string, stepName: string, result: AssemblyResult) { const state = this.getPluginState() const file = state.files[result.original_id] // The `file` may not exist if an import robot was used instead of a file upload. @@ -423,16 +705,19 @@ export default class Transloadit extends BasePlugin { this.setPluginState({ results: [...state.results, entry], }) - this.uppy.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId)) + this.uppy.emit( + 'transloadit:result', + stepName, + result, + this.getAssembly(assemblyId), + ) } /** * When an Assembly has finished processing, get the final state * and emit it. - * - * @param {object} status */ - #onAssemblyFinished (status) { + #onAssemblyFinished(status: AssemblyResponse) { const url = status.assembly_ssl_url this.client.getAssemblyStatus(url).then((finalStatus) => { const assemblyId = finalStatus.assembly_id @@ -447,7 +732,7 @@ export default class Transloadit extends BasePlugin { }) } - async #cancelAssembly (assembly) { + async #cancelAssembly(assembly: AssemblyResponse) { await this.client.cancelAssembly(assembly) // TODO bubble this through AssemblyWatcher so its event handlers can clean up correctly this.uppy.emit('transloadit:assembly-cancelled', assembly) @@ -456,15 +741,19 @@ export default class Transloadit extends BasePlugin { /** * When all files are removed, cancel in-progress Assemblies. */ - #onCancelAll = async ({ reason } = {}) => { + #onCancelAll = async ({ reason }: { reason?: string } = {}) => { try { if (reason !== 'user') return const { uploadsAssemblies } = this.getPluginState() const assemblyIDs = Object.values(uploadsAssemblies).flat(1) - const assemblies = assemblyIDs.map((assemblyID) => this.getAssembly(assemblyID)) + const assemblies = assemblyIDs.map((assemblyID) => + this.getAssembly(assemblyID), + ) - await Promise.all(assemblies.map((assembly) => this.#cancelAssembly(assembly))) + await Promise.all( + assemblies.map((assembly) => this.#cancelAssembly(assembly)), + ) } catch (err) { this.uppy.log(err) } @@ -473,10 +762,15 @@ export default class Transloadit extends BasePlugin { /** * Custom state serialization for the Golden Retriever plugin. * It will pass this back to the `_onRestored` function. - * - * @param {Function} setData */ - #getPersistentData = (setData) => { + #getPersistentData = ( + setData: ( + arg: Record< + string, + Pick + >, + ) => void, + ) => { const { assemblies, uploadsAssemblies } = this.getPluginState() setData({ @@ -487,10 +781,12 @@ export default class Transloadit extends BasePlugin { }) } - #onRestored = (pluginData) => { - const savedState = pluginData && pluginData[this.id] ? pluginData[this.id] : {} - const previousAssemblies = savedState.assemblies || {} - const uploadsAssemblies = savedState.uploadsAssemblies || {} + #onRestored = (pluginData: Record) => { + const savedState = + pluginData && pluginData[this.id] ? pluginData[this.id] : {} + const previousAssemblies = (savedState as TransloaditState).assemblies || {} + const uploadsAssemblies = + (savedState as TransloaditState).uploadsAssemblies || {} if (Object.keys(uploadsAssemblies).length === 0) { // Nothing to restore. @@ -498,14 +794,22 @@ export default class Transloadit extends BasePlugin { } // Convert loaded Assembly statuses to a Transloadit plugin state object. - const restoreState = (assemblies) => { - const files = {} - const results = [] - for (const [id, status] of Object.entries(assemblies)) { + const restoreState = (assemblies: TransloaditState['assemblies']) => { + const files: Record< + string, + { id: string; assembly: string; uploadedFile: AssemblyFile } + > = {} + const results: { + result: AssemblyResult + stepName: string + id: string + assembly: string + }[] = [] + for (const [id, status] of Object.entries(assemblies)) { status.uploads.forEach((uploadedFile) => { const file = this.#findFile(uploadedFile) files[uploadedFile.id] = { - id: file.id, + id: file!.id, assembly: id, uploadedFile, } @@ -566,7 +870,7 @@ export default class Transloadit extends BasePlugin { this.restored = Promise.resolve().then(() => { restoreState(previousAssemblies) restoreAssemblies() - return updateAssemblies() + updateAssemblies() }) this.restored.then(() => { @@ -574,13 +878,13 @@ export default class Transloadit extends BasePlugin { }) } - #connectAssembly (assembly) { + #connectAssembly(assembly: Assembly) { const { status } = assembly const id = status.assembly_id this.activeAssemblies[id] = assembly // Sync local `assemblies` state - assembly.on('status', (newStatus) => { + assembly.on('status', (newStatus: AssemblyResponse) => { const { assemblies } = this.getPluginState() this.setPluginState({ assemblies: { @@ -590,10 +894,10 @@ export default class Transloadit extends BasePlugin { }) }) - assembly.on('upload', (file) => { + assembly.on('upload', (file: AssemblyFile) => { this.#onFileUploadComplete(id, file) }) - assembly.on('error', (error) => { + assembly.on('error', (error: AssemblyError) => { error.assembly = assembly.status // eslint-disable-line no-param-reassign this.uppy.emit('transloadit:assembly-error', assembly.status, error) }) @@ -602,31 +906,34 @@ export default class Transloadit extends BasePlugin { this.uppy.emit('transloadit:assembly-executing', assembly.status) }) - assembly.on('execution-progress', (details) => { - this.uppy.emit('transloadit:execution-progress', details) - - if (details.progress_combined != null) { - // TODO: Transloadit emits progress information for the entire Assembly combined - // (progress_combined) and for each imported/uploaded file (progress_per_original_file). - // Uppy's current design requires progress to be set for each file, which is then - // averaged to get the total progress (see calculateProcessingProgress.js). - // Therefore, we currently set the combined progres for every file, so that this is - // the same value that is displayed to the end user, although we have more accurate - // per-file progress as well. We cannot use this here or otherwise progress from - // imported files would not be counted towards the total progress because imported - // files are not registered with Uppy. - for (const file of this.uppy.getFiles()) { - this.uppy.emit('postprocess-progress', file, { - mode: 'determinate', - value: details.progress_combined / 100, - message: this.i18n('encoding'), - }) + assembly.on( + 'execution-progress', + (details: { progress_combined?: number }) => { + this.uppy.emit('transloadit:execution-progress', details) + + if (details.progress_combined != null) { + // TODO: Transloadit emits progress information for the entire Assembly combined + // (progress_combined) and for each imported/uploaded file (progress_per_original_file). + // Uppy's current design requires progress to be set for each file, which is then + // averaged to get the total progress (see calculateProcessingProgress.js). + // Therefore, we currently set the combined progres for every file, so that this is + // the same value that is displayed to the end user, although we have more accurate + // per-file progress as well. We cannot use this here or otherwise progress from + // imported files would not be counted towards the total progress because imported + // files are not registered with Uppy. + for (const file of this.uppy.getFiles()) { + this.uppy.emit('postprocess-progress', file, { + mode: 'determinate', + value: details.progress_combined / 100, + message: this.i18n('encoding'), + }) + } } - } - }) + }, + ) if (this.opts.waitForEncoding) { - assembly.on('result', (stepName, result) => { + assembly.on('result', (stepName: string, result: AssemblyResult) => { this.#onResult(id, stepName, result) }) } @@ -642,6 +949,7 @@ export default class Transloadit extends BasePlugin { } // No need to connect to the socket if the Assembly has completed by now. + // @ts-expect-error ok does not exist on Assembly? if (assembly.ok === 'ASSEMBLY_COMPLETE') { return assembly } @@ -650,8 +958,8 @@ export default class Transloadit extends BasePlugin { return assembly } - #prepareUpload = (fileIDs, uploadID) => { - const files = fileIDs.map(id => this.uppy.getFile(id)) + #prepareUpload = async (fileIDs: string[], uploadID: string) => { + const files = fileIDs.map((id) => this.uppy.getFile(id)) const filesWithoutErrors = files.filter((file) => { if (!file.error) { this.uppy.emit('preprocess-progress', file, { @@ -663,10 +971,20 @@ export default class Transloadit extends BasePlugin { return false }) - // eslint-disable-next-line no-shadow - const createAssembly = async ({ fileIDs, options }) => { + const createAssembly = async ({ + // eslint-disable-next-line no-shadow + fileIDs, + options, + }: { + fileIDs: string[] + options: OptionsWithRestructuredFields + }) => { try { - const assembly = await this.#createAssembly(fileIDs, uploadID, options) + const assembly = (await this.#createAssembly( + fileIDs, + uploadID, + options, + )) as Assembly if (this.opts.importFromUploadURLs) { await this.#reserveFiles(assembly, fileIDs) } @@ -675,7 +993,7 @@ export default class Transloadit extends BasePlugin { this.uppy.emit('preprocess-complete', file) }) return assembly - } catch (err) { + } catch (err) { fileIDs.forEach((fileID) => { const file = this.uppy.getFile(fileID) // Clear preprocessing state when the Assembly could not be created, @@ -695,15 +1013,23 @@ export default class Transloadit extends BasePlugin { }, }) - const assemblyOptions = new AssemblyOptions(filesWithoutErrors, this.opts) + const assemblyOptions = new AssemblyOptionsBuilder( + filesWithoutErrors, + this.opts, + ) - return assemblyOptions.build() + await assemblyOptions + .build() .then((assemblies) => Promise.all(assemblies.map(createAssembly))) .then((maybeCreatedAssemblies) => { const createdAssemblies = maybeCreatedAssemblies.filter(Boolean) - const assemblyIDs = createdAssemblies.map(assembly => assembly.status.assembly_id) + const assemblyIDs = createdAssemblies.map( + (assembly) => assembly.status.assembly_id, + ) this.#createAssemblyWatcher(assemblyIDs, uploadID) - return Promise.all(createdAssemblies.map(assembly => this.#connectAssembly(assembly))) + return Promise.all( + createdAssemblies.map((assembly) => this.#connectAssembly(assembly)), + ) }) // If something went wrong before any Assemblies could be created, // clear all processing state. @@ -716,10 +1042,12 @@ export default class Transloadit extends BasePlugin { }) } - #afterUpload = (fileIDs, uploadID) => { - const files = fileIDs.map(fileID => this.uppy.getFile(fileID)) + #afterUpload = (fileIDs: string[], uploadID: string): Promise => { + const files = fileIDs.map((fileID) => this.uppy.getFile(fileID)) // Only use files without errors - const filteredFileIDs = files.filter((file) => !file.error).map(file => file.id) + const filteredFileIDs = files + .filter((file) => !file.error) + .map((file) => file.id) const state = this.getPluginState() @@ -756,7 +1084,9 @@ export default class Transloadit extends BasePlugin { return Promise.resolve() } - const incompleteFiles = files.filter(file => !hasProperty(this.completedFiles, file.id)) + const incompleteFiles = files.filter( + (file) => !hasProperty(this.completedFiles, file.id), + ) incompleteFiles.forEach((file) => { this.uppy.emit('postprocess-progress', file, { mode: 'indeterminate', @@ -782,31 +1112,39 @@ export default class Transloadit extends BasePlugin { }) } - #closeAssemblyIfExists = (assemblyID) => { + #closeAssemblyIfExists = (assemblyID?: string) => { + if (!assemblyID) return this.activeAssemblies[assemblyID]?.close() } - #onError = (err = null, uploadID) => { - const state = this.getPluginState() - const assemblyIDs = state.uploadsAssemblies[uploadID] - assemblyIDs?.forEach(this.#closeAssemblyIfExists) + #onError = (err: { name: string; message: string; details?: string }) => { + // TODO: uploadID is not accessible here. The state in core has many upload IDs, + // so we don't know which one to get. This code never worked and no one complained. + // See if we run into problems with this. + // const state = this.getPluginState() + // const assemblyIDs = state.uploadsAssemblies[uploadID] + // assemblyIDs?.forEach(this.#closeAssemblyIfExists) - this.client.submitError(err) + this.client + .submitError(err) // if we can't report the error that sucks .catch(sendErrorToConsole(err)) } - #onTusError = (file, err) => { + #onTusError = (file: UppyFile | undefined, err: Error) => { this.#closeAssemblyIfExists(file?.transloadit?.assembly) if (err?.message?.startsWith('tus: ')) { - const endpoint = err.originalRequest?.getUnderlyingObject()?.responseURL - this.client.submitError(err, { endpoint, type: 'TUS_ERROR' }) + const endpoint = ( + err as TusDetailedError + ).originalRequest?.getUnderlyingObject()?.responseURL as string + this.client + .submitError(err, { endpoint }) // if we can't report the error that sucks .catch(sendErrorToConsole(err)) } } - install () { + install(): void { this.uppy.addPreProcessor(this.#prepareUpload) this.uppy.addPostProcessor(this.#afterUpload) @@ -816,13 +1154,14 @@ export default class Transloadit extends BasePlugin { // Handle cancellation. this.uppy.on('cancel-all', this.#onCancelAll) - // For error reporting. this.uppy.on('upload-error', this.#onTusError) if (this.opts.importFromUploadURLs) { // No uploader needed when importing; instead we take the upload URL from an existing uploader. this.uppy.on('upload-success', this.#onFileUploadURLAvailable) } else { + // @ts-expect-error endpoint has to be required for @uppy/tus but for some reason + // we don't need it here. this.uppy.use(Tus, { // Disable tus-js-client fingerprinting, otherwise uploading the same file at different times // will upload to an outdated Assembly, and we won't get socket events for it. @@ -867,7 +1206,7 @@ export default class Transloadit extends BasePlugin { }) } - uninstall () { + uninstall(): void { this.uppy.removePreProcessor(this.#prepareUpload) this.uppy.removePostProcessor(this.#afterUpload) this.uppy.off('error', this.#onError) @@ -885,19 +1224,16 @@ export default class Transloadit extends BasePlugin { }) } - getAssembly (id) { + getAssembly(id: string): AssemblyResponse { const { assemblies } = this.getPluginState() return assemblies[id] } - getAssemblyFiles (assemblyID) { + getAssemblyFiles(assemblyID: string): UppyFile[] { return this.uppy.getFiles().filter((file) => { return file?.transloadit?.assembly === assemblyID }) } } -export { - COMPANION_URL, - COMPANION_ALLOWED_HOSTS, -} +export { COMPANION_URL, COMPANION_ALLOWED_HOSTS } diff --git a/packages/@uppy/transloadit/src/locale.js b/packages/@uppy/transloadit/src/locale.ts similarity index 100% rename from packages/@uppy/transloadit/src/locale.js rename to packages/@uppy/transloadit/src/locale.ts diff --git a/packages/@uppy/transloadit/tsconfig.build.json b/packages/@uppy/transloadit/tsconfig.build.json new file mode 100644 index 00000000000..25527c98c12 --- /dev/null +++ b/packages/@uppy/transloadit/tsconfig.build.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/tus": ["../tus/src/index.js"], + "@uppy/tus/lib/*": ["../tus/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json" + }, + { + "path": "../provider-views/tsconfig.build.json" + }, + { + "path": "../tus/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/transloadit/tsconfig.json b/packages/@uppy/transloadit/tsconfig.json new file mode 100644 index 00000000000..3ca996af9b0 --- /dev/null +++ b/packages/@uppy/transloadit/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/tus": ["../tus/src/index.js"], + "@uppy/tus/lib/*": ["../tus/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json", + }, + { + "path": "../provider-views/tsconfig.build.json", + }, + { + "path": "../tus/tsconfig.build.json", + }, + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + ], +} diff --git a/packages/@uppy/tus/src/index.ts b/packages/@uppy/tus/src/index.ts index 97adb68164b..9f0072d7992 100644 --- a/packages/@uppy/tus/src/index.ts +++ b/packages/@uppy/tus/src/index.ts @@ -36,6 +36,8 @@ type RestTusUploadOptions = Omit< 'onShouldRetry' | 'onBeforeRequest' | 'headers' > +export type TusDetailedError = tus.DetailedError + export interface TusOpts extends PluginOpts, RestTusUploadOptions { From 730459d6723e3083b8000431c7d8a8220fdb3a46 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 18 Mar 2024 18:32:10 +0200 Subject: [PATCH 11/12] add missing exports (#5009) --- packages/@uppy/drag-drop/src/DragDrop.tsx | 2 +- packages/@uppy/drag-drop/src/index.ts | 1 + packages/@uppy/progress-bar/src/index.ts | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/@uppy/drag-drop/src/DragDrop.tsx b/packages/@uppy/drag-drop/src/DragDrop.tsx index 714e251a9d2..a04ca85953e 100644 --- a/packages/@uppy/drag-drop/src/DragDrop.tsx +++ b/packages/@uppy/drag-drop/src/DragDrop.tsx @@ -13,7 +13,7 @@ import { h, type ComponentChild } from 'preact' import packageJson from '../package.json' import locale from './locale.ts' -interface DragDropOptions extends UIPluginOptions { +export interface DragDropOptions extends UIPluginOptions { inputName?: string allowMultipleFiles?: boolean width?: string | number diff --git a/packages/@uppy/drag-drop/src/index.ts b/packages/@uppy/drag-drop/src/index.ts index 22c89b650c8..ff2edbdd5de 100644 --- a/packages/@uppy/drag-drop/src/index.ts +++ b/packages/@uppy/drag-drop/src/index.ts @@ -1 +1,2 @@ export { default } from './DragDrop.tsx' +export type { DragDropOptions } from './DragDrop.tsx' diff --git a/packages/@uppy/progress-bar/src/index.ts b/packages/@uppy/progress-bar/src/index.ts index a9cebe57d2a..e102c835888 100644 --- a/packages/@uppy/progress-bar/src/index.ts +++ b/packages/@uppy/progress-bar/src/index.ts @@ -1 +1,3 @@ export { default } from './ProgressBar.tsx' + +export type { ProgressBarOptions } from './ProgressBar.tsx' From c306d6c58f9158a6cbfb5ff95acb2103ed91cb0a Mon Sep 17 00:00:00 2001 From: Hiroki Shimizu Date: Tue, 19 Mar 2024 19:27:33 +0900 Subject: [PATCH 12/12] @uppy/aws-s3-multipart: fix escaping issue with client signed request (#5006) --- .../aws-s3-multipart/src/createSignedURL.js | 12 +++++- .../src/createSignedURL.test.js | 40 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/@uppy/aws-s3-multipart/src/createSignedURL.js b/packages/@uppy/aws-s3-multipart/src/createSignedURL.js index 593b327b6e0..bef07e5b9c6 100644 --- a/packages/@uppy/aws-s3-multipart/src/createSignedURL.js +++ b/packages/@uppy/aws-s3-multipart/src/createSignedURL.js @@ -76,6 +76,10 @@ async function hash (key, data) { return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data)) } +function percentEncode(c) { + return `%${c.charCodeAt(0).toString(16).toUpperCase()}` +} + /** * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html * @param {Record} param0 @@ -90,7 +94,13 @@ export default async function createSignedURL ({ }) { const Service = 's3' const host = `${bucketName}.${Service}.${Region}.amazonaws.com` - const CanonicalUri = `/${encodeURI(Key)}` + /** + * List of char out of `encodeURI()` is taken from ECMAScript spec. + * Note that the `/` character is purposefully not included in list below. + * + * @see https://tc39.es/ecma262/#sec-encodeuri-uri + */ + const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, percentEncode)}` const payload = 'UNSIGNED-PAYLOAD' const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ diff --git a/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js b/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js index cfcb12742c2..bb873829eb5 100644 --- a/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js +++ b/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js @@ -47,7 +47,7 @@ describe('createSignedURL', () => { Bucket: bucketName, Fields: {}, Key: 'some/key', - }, { expiresIn: 900 }))).searchParams.get('X-Amz-Signature'), + }), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'), ) }) it('should be able to sign multipart upload', async () => { @@ -71,7 +71,43 @@ describe('createSignedURL', () => { UploadId: uploadId, PartNumber: partNumber, Key: 'some/key', - }, { expiresIn: 900 }))).searchParams.get('X-Amz-Signature'), + }), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'), ) }) + it('should escape path and query as restricted to RFC 3986', async () => { + const client = new S3Client(s3ClientOptions) + const partNumber = 99 + const specialChars = ';?:@&=+$,#!\'()' + const uploadId = `Upload${specialChars}Id` + // '.*' chars of path should be encoded + const Key = `${specialChars}.*/${specialChars}.*.ext` + const implResult = + await createSignedURL({ + accountKey: s3ClientOptions.credentials.accessKeyId, + accountSecret: s3ClientOptions.credentials.secretAccessKey, + sessionToken: s3ClientOptions.credentials.sessionToken, + uploadId, + partNumber, + bucketName, + Key, + Region: s3ClientOptions.region, + expires: 900, + }) + const sdkResult = + new URL( + await getSignedUrl(client, new UploadPartCommand({ + Bucket: bucketName, + UploadId: uploadId, + PartNumber: partNumber, + Key, + }), { expiresIn: 900 } + ) + ) + assert.strictEqual(implResult.pathname, sdkResult.pathname) + + const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/ + const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/ + assert.strictEqual(implResult.search.match(extractUploadId)[2], sdkResult.search.match(extractUploadId)[2]) + assert.strictEqual(implResult.search.match(extractSignature)[2], sdkResult.search.match(extractSignature)[2]) + }) })