@wordpress/upload-media
Version:
Core media upload logic.
478 lines (432 loc) • 13.8 kB
text/typescript
/**
* External dependencies
*/
import { v4 as uuidv4 } from 'uuid';
/**
* WordPress dependencies
*/
import type { createRegistry } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
type WPDataRegistry = ReturnType< typeof createRegistry >;
/**
* Internal dependencies
*/
import type {
AdditionalData,
CancelAction,
OnBatchSuccessHandler,
OnChangeHandler,
OnErrorHandler,
OnSuccessHandler,
QueueItemId,
RetryItemAction,
ScheduleRetryAction,
State,
} from './types';
import { ItemStatus, OperationType, Type } from './types';
import {
calculateRetryDelay,
clearRetryTimer,
retryTimers,
shouldRetryError,
} from './utils/retry';
import type {
addItem,
processItem,
removeItem,
revokeBlobUrls,
} from './private-actions';
import { maybeRecycleVipsWorker, vipsCancelOperations } from './utils';
import { cancelGifToVideoOperations } from './utils/video-conversion';
import { debug } from './utils/debug-logger';
import { ErrorCode, UploadError } from '../upload-error';
import { validateMimeType } from '../validate-mime-type';
import { validateMimeTypeForUser } from '../validate-mime-type-for-user';
import { validateFileSize } from '../validate-file-size';
type ActionCreators = {
addItem: typeof addItem;
addItems: typeof addItems;
removeItem: typeof removeItem;
processItem: typeof processItem;
cancelItem: typeof cancelItem;
retryItem: typeof retryItem;
scheduleRetry: typeof scheduleRetry;
executeRetry: typeof executeRetry;
revokeBlobUrls: typeof revokeBlobUrls;
< T = Record< string, unknown > >( args: T ): void;
};
type AllSelectors = typeof import('./selectors') &
typeof import('./private-selectors');
type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R
? ( ...args: P ) => R
: F;
type Selectors = {
[ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >;
};
type ThunkArgs = {
select: Selectors;
dispatch: ActionCreators;
registry: WPDataRegistry;
};
interface AddItemsArgs {
files: File[];
onChange?: OnChangeHandler;
onSuccess?: OnSuccessHandler;
onBatchSuccess?: OnBatchSuccessHandler;
onError?: OnErrorHandler;
additionalData?: AdditionalData;
allowedTypes?: string[];
}
/**
* Adds a new item to the upload queue.
*
* @param $0
* @param $0.files Files
* @param [$0.onChange] Function called each time a file or a temporary representation of the file is available.
* @param [$0.onSuccess] Function called after the file is uploaded.
* @param [$0.onBatchSuccess] Function called after a batch of files is uploaded.
* @param [$0.onError] Function called when an error happens.
* @param [$0.additionalData] Additional data to include in the request.
* @param [$0.allowedTypes] Array with the types of media that can be uploaded, if unset all types are allowed.
*/
export function addItems( {
files,
onChange,
onSuccess,
onError,
onBatchSuccess,
additionalData,
allowedTypes,
}: AddItemsArgs ) {
return async ( { select, dispatch }: ThunkArgs ) => {
const batchId = uuidv4();
for ( const file of files ) {
/*
Check if the caller (e.g. a block) supports this mime type.
Special case for file types such as HEIC which will be converted before upload anyway.
Another check will be done before upload.
*/
try {
validateMimeType( file, allowedTypes );
validateMimeTypeForUser(
file,
select.getSettings().allowedMimeTypes
);
} catch ( error: unknown ) {
onError?.( error as Error );
continue;
}
try {
validateFileSize(
file,
select.getSettings().maxUploadFileSize
);
} catch ( error: unknown ) {
onError?.( error as Error );
continue;
}
dispatch.addItem( {
file,
batchId,
onChange,
onSuccess,
onBatchSuccess,
onError,
additionalData,
} );
}
};
}
/**
* Cancels an item in the queue based on an error.
*
* If the error is retryable and the item hasn't exceeded the maximum
* retry attempts, it will be scheduled for automatic retry instead
* of being cancelled.
*
* @param id Item ID.
* @param error Error instance.
* @param silent Whether to cancel the item silently,
* without invoking its `onError` callback.
*/
export function cancelItem( id: QueueItemId, error: Error, silent = false ) {
return async ( { select, dispatch }: ThunkArgs ) => {
const item = select.getItem( id );
if ( ! item ) {
/*
* Do nothing if item has already been removed.
* This can happen if an upload is cancelled manually
* while transcoding with vips is still in progress.
* Then, cancelItem() is once invoked manually and once
* by the error handler in optimizeImageItem().
*/
return;
}
// Clear any pending retry timer for this item.
clearRetryTimer( id );
// Check if we should automatically retry instead of cancelling.
// Child sideload items are excluded: the parent owns the upload
// lifecycle and decides whether a sub-size failure should cancel
// the whole attachment or keep the partially-uploaded sub-sizes.
// Items whose primary upload already finished (attachment exists)
// are also excluded — the cancellation is cleanup, not a retry.
if ( ! silent && error && ! item.parentId && ! item.attachment?.id ) {
const settings = select.getSettings();
const retrySettings = settings.retry;
if ( retrySettings ) {
const retryCount = item.retryCount ?? 0;
const maxRetries = retrySettings.maxRetryAttempts;
if ( shouldRetryError( error, retryCount, maxRetries ) ) {
dispatch.scheduleRetry( id, error );
return;
}
}
}
item.abortController?.abort();
// Cancel any ongoing vips operations for this item.
await vipsCancelOperations( id );
/*
* Cancel any ongoing GIF-to-video conversion for this item so a
* cancelled upload does not leave the encoder running off-thread.
*/
await cancelGifToVideoOperations( id );
if ( ! silent ) {
const { onError } = item;
onError?.( error ?? new Error( 'Upload cancelled' ) );
if ( ! onError && error && ! item.parentId ) {
// Log errors for top-level items without an onError handler.
// Child sideload errors are suppressed here because the
// parent will be notified and surface the error to the user.
// eslint-disable-next-line no-console -- Deliberately log errors here.
console.error( 'Upload cancelled', error );
}
} else {
debug(
`Item cancelled: ${ item.file.name } (item ${ id }): ${
error instanceof Error ? error.message : error
}`
);
}
const { currentOperation, parentId, batchId } = item;
dispatch< CancelAction >( {
type: Type.Cancel,
id,
error,
} );
dispatch.removeItem( id );
dispatch.revokeBlobUrls( id );
// A concurrency slot just freed up. Kick any items that were
// waiting in the queue, mirroring finishOperation's behavior.
if (
currentOperation === OperationType.ResizeCrop ||
currentOperation === OperationType.Rotate
) {
for ( const pending of select.getPendingImageProcessing() ) {
dispatch.processItem( pending.id );
}
}
if ( currentOperation === OperationType.Upload ) {
for ( const pending of select.getPendingUploads() ) {
dispatch.processItem( pending.id );
}
}
if ( currentOperation === OperationType.TranscodeGif ) {
for ( const pending of select.getPendingVideoProcessing() ) {
dispatch.processItem( pending.id );
}
}
// Failed vips ops also leak WASM memory, so count them toward the
// recycle budget. Without this, a long burst of failures (e.g. a
// gallery of unsupported AVIFs) could grow memory unbounded.
if (
currentOperation === OperationType.ResizeCrop ||
currentOperation === OperationType.Rotate ||
currentOperation === OperationType.TranscodeImage
) {
maybeRecycleVipsWorker( select.getActiveImageProcessingCount() );
}
// If this was a child sideload item, handle the parent.
if ( parentId ) {
const parentItem = select.getItem( parentId );
if ( parentItem ) {
/*
* The converted video and its poster are optional companions
* of an animated GIF: the parent GIF attachment is fine
* without them. Their failure must never be treated as a total
* parent failure (which would delete the already-uploaded GIF),
* even when the companion is the only child sideload.
*/
const isOptionalCompanion =
item.additionalData?.image_size === 'animated_video' ||
item.additionalData?.image_size === 'animated_video_poster';
if ( select.hasPendingItemsByParentId( parentId ) ) {
// Other children remain — just notify the parent so
// it can re-check the Finalize gate.
if (
parentItem.operations &&
parentItem.operations.length > 0
) {
dispatch.processItem( parentId );
}
} else if (
( parentItem.subSizes && parentItem.subSizes.length > 0 ) ||
isOptionalCompanion
) {
/*
* Partial success: at least one child sideload succeeded
* (its sub-size is already accumulated on the parent), or
* the failed child was an optional companion. Keep the
* parent attachment and finalize with whichever sub-sizes
* did succeed — matching WordPress core's best-effort
* behavior when individual sub-size generations fail.
*/
if (
parentItem.operations &&
parentItem.operations.length > 0
) {
dispatch.processItem( parentId );
}
} else {
// Total failure: no child succeeded. The parent file
// already uploaded — delete the orphaned attachment
// from the server so it doesn't appear in the media
// library.
const parentAttachmentId = parentItem.attachment?.id;
const { mediaDelete } = select.getSettings();
if ( parentAttachmentId && mediaDelete ) {
mediaDelete( parentAttachmentId ).catch( () => {
// Best-effort cleanup; surface nothing to the
// user if the delete itself fails.
} );
}
// Cancel the parent too so the block resets rather
// than showing a partial upload. Propagate the
// underlying error's code and message — vips
// processing failures already carry an actionable
// hint at their source; network/server failures
// surface their real cause. Awaited so the cascade
// fully settles (parent removed, onError fired) before
// this thunk resolves and the batch-completion check
// below runs.
await dispatch.cancelItem(
parentId,
new UploadError( {
code:
( error instanceof UploadError &&
error.code ) ||
ErrorCode.GENERAL,
message:
error?.message ||
__( 'The image could not be uploaded.' ),
file: parentItem.file,
cause: error instanceof Error ? error : undefined,
} )
);
}
}
}
// All items of this batch were cancelled or finished.
if ( batchId && select.isBatchUploaded( batchId ) ) {
debug( `Batch completed: ${ batchId }` );
item.onBatchSuccess?.();
}
};
}
/**
* Retries a failed item in the queue.
*
* @param id Item ID.
*/
export function retryItem( id: QueueItemId ) {
return async ( { select, dispatch }: ThunkArgs ) => {
const item = select.getItem( id );
if ( ! item ) {
return;
}
// Only retry items that have an error.
if ( ! item.error ) {
return;
}
dispatch< RetryItemAction >( {
type: Type.RetryItem,
id,
} );
dispatch.processItem( id );
};
}
/**
* Schedules an automatic retry for a failed item.
*
* Uses exponential backoff with jitter to determine the retry delay.
* The item will be placed in PendingRetry status and automatically
* retried after the calculated delay.
*
* @param id Item ID.
* @param error The error that caused the failure.
*/
export function scheduleRetry( id: QueueItemId, error: Error ) {
return async ( { select, dispatch }: ThunkArgs ) => {
const item = select.getItem( id );
if ( ! item ) {
return;
}
const settings = select.getSettings();
const retrySettings = settings.retry;
if ( ! retrySettings ) {
return;
}
const currentRetryCount = item.retryCount ?? 0;
const delay = calculateRetryDelay( {
attempt: currentRetryCount + 1,
initialDelay: retrySettings.initialRetryDelayMs,
maxDelay: retrySettings.maxRetryDelayMs,
multiplier: retrySettings.backoffMultiplier,
jitter: retrySettings.retryJitter,
} );
// Schedule the retry execution and store timer ID for cleanup.
const timerId = setTimeout( () => {
retryTimers.delete( id );
dispatch.executeRetry( id );
}, delay );
retryTimers.set( id, timerId );
dispatch< ScheduleRetryAction >( {
type: Type.ScheduleRetry,
id,
error,
retryCount: currentRetryCount,
nextRetryTimestamp: Date.now() + delay,
} );
};
}
/**
* Executes a scheduled retry for an item.
*
* This is called by the timer set in scheduleRetry.
* It verifies the item is still in PendingRetry status before
* proceeding with the retry.
*
* @param id Item ID.
*/
export function executeRetry( id: QueueItemId ) {
return async ( { select, dispatch }: ThunkArgs ) => {
const item = select.getItem( id );
// Verify item exists and is still pending retry
// (user may have manually cancelled or retried).
if ( ! item || item.status !== ItemStatus.PendingRetry ) {
return;
}
// If the queue is paused, leave the item in PendingRetry without
// mutating state. resumeQueue will re-trigger executeRetry for
// items in this status when the queue resumes.
if ( select.isPaused() ) {
return;
}
// Reset the item to Processing status and clear the error.
dispatch< RetryItemAction >( {
type: Type.RetryItem,
id,
} );
// Re-process the item.
dispatch.processItem( id );
};
}