Optimiser le téléversement de fichiers dans les applications web
Les performances d’un téléversement incluent le temps consacré à préparer un fichier, à le transférer, à réessayer les requêtes perdues et à attendre la validation du serveur. Ce DevTip crée un worker de préparation d’images assorti de limites et une intégration de téléversement avec reprise dans le navigateur, avec Uppy comme interface alternative. Mesurez ces étapes sur vos propres appareils et votre réseau avant de choisir les paramètres de compression ou d’augmenter le nombre de transferts simultanés.
Ces exemples s’intègrent dans une application existante utilisant des sessions et respectant le
contrat de passerelle ci-dessous. Exécutez la configuration et les compilations à la racine du
projet Yarn 4 de cette application, qui contient un fichier package.json et un
répertoire public/. Les commandes mettent à jour les dépendances et remplacent
les ressources compilées nommées ; elles ne créent ni ne démarrent de serveur d’application.
Node 26.11.1 et Yarn 4.18.1 sont les versions testées pour la compilation, pas des versions minimales
intrinsèquement requises pour les téléversements depuis le navigateur. Installez Yarn séparément
en suivant son guide d’installation. Le navigateur doit prendre en
charge les workers de type module, createImageBitmap, OffscreenCanvas
et AbortSignal.any()/timeout().
Difficultés courantes liées aux téléversements de fichiers
Les fichiers volumineux peuvent épuiser la mémoire, les réseaux lents peuvent provoquer des expirations de délai et les interruptions peuvent laisser le navigateur dans l’incertitude quant au succès d’une requête. Une interface utile distingue les octets transférés de l’acceptation par le serveur. La sécurité dépend de la vérification que l’utilisateur authentifié est propriétaire des fichiers et de la validation côté serveur, même lorsque le navigateur rejette d’abord les fichiers manifestement inadaptés.
Bonnes pratiques pour optimiser les performances des téléversements de fichiers
1. Utiliser des téléversements par blocs
Utilisez un protocole permettant la reprise avec des positions explicites plutôt que d’envoyer des fragments de fichier anonymes. L’intégration tus ci-dessous utilise des blocs de 1 MiB et des nouvelles tentatives limitées. Cela limite la quantité de données à retransmettre ; cela ne garantit pas un débit supérieur sur chaque connexion. Les échecs HTTP non récupérés font échouer l’opération, y compris ceux qui surviennent après que le dernier octet a atteint le serveur.
2. Mettre en œuvre la compression côté client
Le redimensionnement d’images peut réduire la quantité d’octets au prix de ressources CPU, de qualité et de métadonnées. Le worker facultatif ci-dessous accepte des fichiers JPEG ou PNG jusqu’à 10 MiB, limite les images décodées à 16 millions de pixels, ajuste les deux dimensions pour qu’elles tiennent dans 1024 × 1024 et produit un JPEG. Les zones transparentes deviennent blanches et les métadonnées du fichier d’entrée ne sont pas copiées. L’encodeur JPEG peut ajouter ses propres métadonnées, comme la résolution. La conversion des couleurs par le navigateur peut modifier les couleurs, y compris dans les JPEG CMYK ; inspectez le résultat et conservez l’original lorsque ces modifications sont inacceptables. Le décodage peut allouer de la mémoire avant que les dimensions soient connues ; ce mécanisme pratique impose des limites pour les photos ordinaires des utilisateurs, mais ne protège pas contre les décodeurs d’images malveillants.
Le décodage par le navigateur ne permet pas non plus d’établir qu’un fichier d’entrée endommagé est intact. La validation du contenu côté serveur s’applique toujours au JPEG préparé. La préparation est soumise à un délai de 30 secondes ; un appareil lent peut dépasser ce délai même avec une image qui remplit les critères.
Créez image-worker.js :
self.onmessage = async ({ data: file }) => {
let bitmap
try {
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!['image/jpeg', 'image/png'].includes(file.type)
) {
throw new Error('Unsupported image')
}
bitmap = await createImageBitmap(file)
if (bitmap.width * bitmap.height > 16_000_000) throw new Error('Image too large')
const scale = Math.min(1, 1024 / bitmap.width, 1024 / bitmap.height)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
context.fillStyle = 'white'
context.fillRect(0, 0, width, height)
context.drawImage(bitmap, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 })
if (blob.type !== 'image/jpeg' || blob.size === 0) throw new Error('Encoding failed')
self.postMessage({ blob })
} catch {
self.postMessage({ error: 'Image preparation failed.' })
} finally {
bitmap?.close()
}
}
3. Utiliser les Web Workers pour le traitement en arrière-plan
Les workers sont utiles ici pour le décodage et l’encodage. Fetch et le découpage de Blob ne
nécessitent pas un worker par requête. Cette fonction d’encapsulation
prepare-image.js termine son unique worker en cas de succès, d’erreur d’exécution,
d’erreur de décodage de message, d’annulation, d’expiration du délai ou d’échec de
postMessage :
export function prepareImage(file, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' })
let settled = false
let timer
const finish = (error, blob) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
worker.terminate()
if (error) reject(error)
else resolve(new File([blob], 'upload.jpg', { type: 'image/jpeg' }))
}
const abort = () => finish(new Error('Image preparation canceled.'))
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Image preparation timed out.')), 30_000)
worker.onerror = () => finish(new Error('Image worker failed.'))
worker.onmessageerror = () => finish(new Error('Invalid worker message.'))
worker.onmessage = ({ data }) => {
if (
!(data?.blob instanceof Blob) ||
data.blob.size === 0 ||
data.blob.type !== 'image/jpeg'
) {
finish(new Error('Image preparation failed.'))
return
}
finish(null, data.blob)
}
try {
worker.postMessage(file)
} catch {
finish(new Error('Could not start image preparation.'))
}
})
}
Consultez la documentation de la plateforme pour OffscreenCanvas.convertToBlob et Worker.terminate.
Assurer la sécurité des téléversements de fichiers
1. Valider les types et les tailles des fichiers
Les fonctions de téléversement acceptent des fichiers jusqu’à 10 MiB. Les types MIME, noms et tailles fournis par le client sont des métadonnées non fiables. Le serveur doit limiter indépendamment le nombre d’octets, détecter les contenus pris en charge et appliquer sa politique d’analyse de sécurité ou de décodage avant de rendre un fichier téléversé accessible.
2. Utiliser un stockage de fichiers sécurisé
Les exemples pour le navigateur ci-dessous nécessitent une application existante avec
authentification, de même origine, disposant d’un point de terminaison tus à
/api/tus/files/. Ce tutoriel porte sur le client ; il ne met pas en place ce serveur.
Le serveur tus Node.js maintenu fournit les implémentations du
protocole et du stockage ; la passerelle de votre application doit fournir la gestion des sessions
et la politique de publication. Configurez la passerelle pour valider la session et
X-CSRF-TOKEN lors des mutations et vérifier X-Upload-Owner par
rapport à la session pour chaque requête tus. Imposez un total de 10 MiB, des blocs de 1 MiB, des
limites de stockage et de débit par utilisateur, une expiration et un stockage temporaire privé.
Vérifiez la propriété lors de la création, des requêtes HEAD, PATCH et de toute requête de
terminaison. Rejetez les redirections et n’émettez que des URL Location de même origine sous
/api/tus/files/.
Une fois que tus signale la fin du transfert, POST /api/upload-publications accepte
{uploadUrl}. Le serveur doit résoudre cette URL uniquement à partir de ses propres
enregistrements de téléversements dont la propriété a été vérifiée, sans jamais récupérer une URL
arbitraire. Il vérifie que le transfert est complet et valide le contenu, puis effectue une
publication atomique unique ; les requêtes en double renvoient le même reçu avec HTTP 200 ou 201.
Une réponse d’échec ou 202 ne confirme pas la publication. Renvoyez
{id} contenant un identifiant de reçu opaque et non vide. L’accès au
téléchargement doit faire l’objet d’une autorisation distincte pour l’utilisateur qui le demande.
Des noms de fichiers aléatoires ne suffisent pas à contrôler l’accès.
Mettre en œuvre des téléversements avec reprise via tus
Le nom du protocole est tus. Installez la version testée du client dans votre projet pour le navigateur :
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact tus-js-client@4.3.1 &&
yarn add --dev --exact esbuild@0.28.2)
Créez upload.js. Transmettez l’identifiant actuel du propriétaire et le jeton
CSRF depuis votre page authentifiée ; aucun des deux n’est un Auth Secret de Transloadit. Cette
fonction réessaie les transferts interrompus au cours d’une même session de page. Elle n’associe
pas automatiquement un fichier nouvellement sélectionné à un téléversement précédent sur la seule
base de son nom. Un téléversement abandonné ou ayant échoué expire sur le serveur ; un nouvel appel
crée un nouveau téléversement.
import { Upload } from 'tus-js-client'
export async function uploadFile(file, { owner, csrf, signal, onProgress }) {
signal.throwIfAborted()
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!owner ||
!csrf
) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const headers = { 'X-CSRF-TOKEN': csrf, 'X-Upload-Owner': owner }
const uploadUrl = await new Promise((resolve, reject) => {
let settled = false
let timer
const finish = (error, url) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
if (error) {
void upload.abort().catch(() => {})
reject(error)
} else resolve(url)
}
const abort = () => finish(new Error('Upload canceled.'))
const upload = new Upload(file, {
endpoint: '/api/tus/files/',
headers,
httpStack: {
getName() { return 'SameOriginFetch' },
createRequest(method, url) {
const controller = new AbortController()
const requestHeaders = new Headers()
return {
getMethod() { return method },
getURL() { return url },
getUnderlyingObject() { return controller },
setHeader(name, value) { requestHeaders.set(name, value) },
getHeader(name) { return requestHeaders.get(name) },
setProgressHandler() {},
async abort() { controller.abort() },
async send(body) {
const response = await fetch(url, {
method, body, headers: requestHeaders,
credentials: 'same-origin', redirect: 'error', signal: controller.signal,
})
const text = response.ok ? await response.text() : ''
return {
getStatus() { return response.status },
getHeader(name) { return response.headers.get(name) },
getBody() { return text },
getUnderlyingObject() { return response },
}
},
}
},
},
chunkSize: 1024 * 1024,
storeFingerprintForResuming: false,
retryDelays: [0, 1000, 3000],
onShouldRetry(error) {
const status = error.originalResponse?.getStatus() ?? 0
return status === 0 || status === 409 || status === 423 || status === 429 || status >= 500
},
onBeforeRequest(request) {
const url = new URL(request.getURL(), location.href)
if (url.origin !== location.origin || !url.pathname.startsWith('/api/tus/files/')) {
throw new Error('Unexpected upload URL')
}
},
onProgress(loaded, total) {
try {
onProgress(Math.min(99, Math.floor((100 * loaded) / total)))
} catch {
finish(new Error('Progress display failed.'))
}
},
onError() {
finish(new Error('Upload failed.'))
},
onSuccess() {
if (!upload.url) finish(new Error('Missing upload URL.'))
else finish(null, upload.url)
},
})
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Upload timed out.')), 120_000)
try {
upload.start()
} catch {
finish(new Error('Could not start upload.'))
}
})
signal.throwIfAborted()
const response = await fetch('/api/upload-publications', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadUrl }),
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Publication not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (typeof receipt.id !== 'string' || !receipt.id) throw new Error('Invalid publication receipt.')
onProgress(100)
return receipt.id
}
Cet exemple utilise les véritables options et fonctions de rappel de tus-js-client. Son petit transport Fetch implémente l’interface de pile HTTP du client pour refuser les redirections avant de les suivre. La progression avance aux limites des blocs tus plutôt que de suivre les octets en cours de transfert. Après la perte d’une réponse, un navigateur peut renvoyer une requête avant que tus ne se synchronise avec la position enregistrée ; le serveur doit rejeter une position périmée sans ajouter d’octets en double. Le délai de deux minutes limite l’ensemble du transfert, y compris les nouvelles tentatives. La perte d’une réponse de publication laisse le résultat incertain : vérifiez la liste des téléversements de votre serveur avant d’en démarrer un autre. Une connexion lente peut atteindre ce délai même en dessous de 10 MiB. La publication finale dispose d’un délai distinct de 30 secondes. L’annulation arrête le travail du client, mais ne prouve pas que le serveur a supprimé un fichier téléversé ou empêché sa publication ; les données temporaires abandonnées doivent expirer sur le serveur.
Pour une intégration complète dans une page, générez les attributs échappés
data-upload-owner et data-csrf sur <html>
depuis votre page utilisant une session, puis ajoutez :
<label>File <input id="file" type="file" /></label>
<label><input id="resize" type="checkbox" /> Prepare JPEG or PNG as JPEG</label>
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button">Cancel</button>
<progress id="progress" aria-label="Upload progress" max="100" value="0"></progress>
<p id="status" role="status"></p>
<script type="module" src="/main.js"></script>
Créez main.js :
import { prepareImage } from './prepare-image.js'
import { uploadFile } from './upload.js'
const input = document.getElementById('file')
const button = document.getElementById('upload')
const status = document.getElementById('status')
const resize = document.getElementById('resize')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
resize.disabled = true
document.getElementById('progress').value = 0
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (resize.checked) {
file = await prepareImage(file, controller.signal)
}
await uploadFile(file, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
document.getElementById('progress').value = value
},
})
status.textContent = 'Upload published.'
} catch {
status.textContent = 'Upload failed or canceled. Check your uploads before retrying.'
} finally {
controller = null
button.disabled = false
input.disabled = false
resize.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Regroupez le point d’entrée principal et servez le worker à ses côtés sur la même origine HTTPS :
(command -v node >/dev/null && command -v yarn >/dev/null && command -v cp >/dev/null &&
yarn esbuild main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/main.js &&
cp image-worker.js public/image-worker.js)
Utiliser Uppy pour une expérience utilisateur fluide
Uppy fournit une interface de sélection, de restrictions, de progression et d’annulation. Pour
cette passerelle, utilisez l’interface de téléversement personnalisée d’Uppy pour appeler la
même fonction uploadFile. La publication reste ainsi une condition du
succès, avec le même délai, les mêmes en-têtes d’authentification et la même gestion des échecs.
Il s’agit d’un point d’entrée alternatif, pas d’un outil de téléversement supplémentaire à monter
à côté de la page précédente.
Installez des paquets compatibles :
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact @uppy/core@6.2.0 @uppy/dashboard@6.0.1)
Créez uppy-main.js et montez-le sur une page contenant
<div id="drag-drop-area"></div> et les mêmes attributs de session. L’application doit appeler la
fonction de nettoyage renvoyée lorsqu’elle retire cette interface.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { uploadFile } from './upload.js'
export function mountUploader() {
const uppy = new Uppy({
restrictions: { maxNumberOfFiles: 1, maxFileSize: 10 * 1024 * 1024, minFileSize: 1 },
}).use(Dashboard, { inline: true, target: '#drag-drop-area' })
let active = null
let destroyed = false
const cancel = () => {
const previous = active
active = null
previous?.controller.abort()
}
uppy.on('cancel-all', cancel)
uppy.on('file-removed', (file) => {
if (active?.fileId === file.id) cancel()
})
uppy.addUploader(async (ids) => {
if (active) throw new Error('An upload is already running.')
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
const operation = { fileId: file.id, controller: new AbortController() }
const uploadStarted = Date.now()
active = operation
const current = () => !destroyed && active === operation && Boolean(uppy.getFile(file.id))
uppy.emit('upload-start', [file])
try {
const id = await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: operation.controller.signal,
onProgress(value) {
if (!current()) return
uppy.emit('upload-progress', file, {
uploadStarted,
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
if (current()) uppy.emit('upload-success', file, { status: 200, body: { id } })
} catch {
if (!current()) return
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
if (active === operation) active = null
}
})
return () => {
destroyed = true
cancel()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Utilisez ce balisage à la place des contrôles et du script de la page de base, avec les mêmes
attributs de session sur <html> :
<link rel="stylesheet" href="/uppy-main.css" />
<div id="drag-drop-area"></div>
<script type="module" src="/uppy-main.js"></script>
Compilez le point d’entrée alternatif :
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn esbuild uppy-main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/uppy-main.js)
L’annulation retire l’opération en cours avant d’accepter un autre fichier, afin que les fonctions
de rappel tardives ne puissent ni marquer l’opération suivante comme ayant échoué, ni effacer sa
référence d’annulation. Lors de pagehide, cette intégration détruit le
Dashboard et annule le travail actif du client ; une restauration d’une page conservée monte un
Dashboard vide. Sélectionnez de nouveau le fichier à votre retour. Les téléversements depuis des
fournisseurs distants nécessitent une intégration Companion distincte avec authentification ;
cet exemple accepte uniquement les Blob locaux. Consultez
l’API de téléversement d’Uppy.
Confirmer le résultat publié
Sur la page de base, choisissez un fichier, activez éventuellement la préparation d’images, puis sélectionnez Upload. La page attend un reçu valide du serveur avant d’afficher Upload published.
L’état correspondant dans Uppy est Complete. Une réponse 202, un échec de validation ou une expiration du délai laisse le téléversement non confirmé, même lorsque tous les octets ont été envoyés. Consultez la liste des téléversements accessible après authentification avant de réessayer une publication incertaine ; un nouvel appel crée de nouvelles données temporaires.
Pour un fichier non préparé, comparez le nombre d’octets stockés et le SHA-256 avec ceux du fichier sélectionné à l’aide des outils d’inspection autorisés de votre application. Pour une image préparée, inspectez le JPEG obtenu : aucune dimension ne dépasse 1024, ses proportions sont conservées aux arrondis près et les zones transparentes ont un fond blanc. Le redimensionnement et l’encodage JPEG peuvent augmenter la taille d’un petit PNG ; mesurez donc la taille du fichier préparé et sa qualité visuelle avant d’activer la préparation par défaut.
Ressources complémentaires
- Protocole tus
- Uppy Dashboard
- API Web Workers
- Gestion des téléversements de fichiers avec Transloadit
Réponses aux questions courantes
Comment confirmer un téléversement ? Le nombre d’octets transférés ne suffit pas. Confirmez un reçu authentifié du serveur après validation et publication. L’analyse de logiciels malveillants propre à chaque fournisseur constitue une intégration distincte ; elle ne dispose d’aucune commande CLI universelle de vérification des téléversements.
Qu’est-ce qu’un téléversement de fichiers sans restrictions ? Il s’agit d’un point de terminaison de téléversement qui accepte du contenu sans contraintes suffisantes, ce qui peut permettre le stockage ou l’exécution de fichiers dangereux. Effectuez la validation sur le serveur et isolez le stockage du contenu web exécutable.
Quel encodage de formulaire HTML permet le téléversement de fichiers ? Un formulaire
traditionnel utilise method="post" et enctype="multipart/form-data", avec un champ
de sélection de fichier doté d’un nom. Lorsque vous envoyez FormData avec
Fetch ou Axios dans le navigateur, laissez le navigateur définir le délimiteur multipart ;
n’inventez pas vous-même l’en-tête.
