L’upload de fichiers donne l’impression d’être un problème résolu. Vous avez vu les tutoriels. Vous installez Multer, vous le câblez à une route Express, et c’est fait en dix minutes. Vous livrez.
Puis la production arrive.
Un utilisateur uploade une vidéo de 200 Mo via une connexion mobile instable. La requête tombe à mi-chemin et il doit recommencer depuis le début — ou pire, votre serveur avale silencieusement le fichier incomplet et renvoie un 200. Un autre utilisateur uploade 50 photos de produits d’un coup et votre CPU monte à 100% pendant que Sharp redimensionne les images de façon synchrone dans le handler de la requête. Votre processus Node manque de mémoire et plante. Un troisième utilisateur uploade un fichier soigneusement conçu qui passe votre vérification d’extension. Vous le découvrez une semaine plus tard.
Rien de tout cela n’apparaît dans les tutoriels de dix minutes.
Cet article décrit ce qu’il faut vraiment faire pour construire un système d’upload de fichiers de qualité production en TypeScript avec Express — puis montre comment Uploadista gère cette complexité pour que vous n’ayez pas à le faire.
L’approche naïve
Voici ce dont se dote la plupart des projets TypeScript au départ :
npm install express multer
npm install -D @types/express @types/multer typescript ts-node
import express from "express";
import multer from "multer";
import path from "node:path";
const app = express();
const storage = multer.diskStorage({
destination: "./uploads",
filename: (_req, file, cb) => {
const uniqueName = `${Date.now()}-${file.originalname}`;
cb(null, uniqueName);
},
});
const upload = multer({ storage });
app.post("/upload", upload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "Aucun fichier uploadé" });
}
res.json({ filename: req.file.filename, path: req.file.path });
});
app.listen(3000, () => console.log("Serveur démarré sur le port 3000"));
Ça fonctionne. Pour des fichiers légers, depuis un navigateur de bureau, sur une connexion rapide, sans exigence de traitement, vers un système de fichiers sur un seul serveur.
Ajoutez n’importe quelle contrainte réelle et les fissures apparaissent rapidement.
Ce que les tutoriels passent sous silence
Les uploads reprises
Le modèle d’une requête par upload a un défaut rédhibitoire : si la connexion tombe, vous repartez de zéro. Pour une photo d’avatar de 500 Ko, c’est acceptable. Pour une vidéo de 2 Go, c’est éliminatoire.
Les uploads reprises — où un client peut mettre en pause et reprendre depuis l’endroit où il s’est arrêté — nécessitent des transferts par morceaux. Vous devez accepter des uploads partiels, suivre quels morceaux ont été reçus, stocker l’état entre les requêtes, et réassembler le fichier une fois que tous les morceaux sont arrivés. Vous avez aussi besoin d’un protocole pour que le client puisse interroger la progression de l’upload et reprendre depuis un offset.
Multer ne fait pas ça. Il faudrait l’implémenter par-dessus, probablement en suivant le protocole tus, ce qui implique une bonne gestion d’état et quelques lectures de spécification HTTP.
Un suivi de progression précis
Même pour les uploads qui ne tombent pas, les utilisateurs veulent voir la progression. L’événement progress sur XMLHttpRequest ou les flux de l’API Fetch vous indiquera combien d’octets ont été envoyés côté client. Mais ce n’est pas la même chose que le nombre d’octets que votre serveur a reçus, traités ou stockés.
Si vous traitez le fichier (redimensionner une image, transcoder une vidéo) après l’avoir reçu, la barre de progression devrait refléter ça aussi. Sinon, elle saute à 100% et les utilisateurs attendent ensuite devant une interface figée pendant que votre serveur travaille.
Traitement de fichiers sans bloquer votre serveur
Redimensionner une image de façon synchrone dans un handler de requête Express bloque la boucle événementielle de Node.js pendant que Sharp opère. Ça fonctionne bien pour une requête. Ça s’effondre sous la charge.
La solution classique est une file de jobs : BullMQ ou similaire pour déléguer le traitement à des workers en arrière-plan. Mais maintenant vous avez besoin de Redis, d’un processus worker, d’une logique de retry, et d’un moyen de prévenir le client quand le traitement est terminé.
npm install bullmq sharp
npm install -D @types/sharp
import { Queue, Worker } from "bullmq";
import sharp from "sharp";
const imageQueue = new Queue("image-processing", {
connection: { host: "localhost", port: 6379 },
});
// Ajouter à la queue après l'upload
await imageQueue.add("resize", {
inputPath: req.file.path,
outputPath: `./uploads/resized-${req.file.filename}`,
width: 1200,
height: 800,
});
// Dans un processus worker séparé
const worker = new Worker(
"image-processing",
async (job) => {
await sharp(job.data.inputPath)
.resize(job.data.width, job.data.height, { fit: "cover" })
.toFile(job.data.outputPath);
},
{ connection: { host: "localhost", port: 6379 } }
);
C’est correct, mais vous venez d’ajouter Redis comme dépendance obligatoire, et maintenant vous avez deux processus à déployer, surveiller et maintenir synchronisés. Et il vous manque toujours les uploads reprises et la progression en temps réel.
La récupération d’erreurs
Que se passe-t-il quand votre job de traitement échoue à mi-chemin ? Vous avez un fichier d’entrée orphelin sur S3, une sortie partielle, et un job bloqué en état d’erreur. Votre utilisateur a reçu un 200 quand l’upload a réussi, mais il n’a jamais obtenu son fichier traité.
Relancez-vous seulement le traitement ? Recommencez-vous depuis le début ? Comment communiquez-vous l’échec au client ? Si vous réessayez, le faites-vous immédiatement ou avec un backoff exponentiel ? Que se passe-t-il si le même fichier échoue trois fois ?
Ce ne sont pas des cas limites. C’est l’état normal des systèmes distribués.
Stocker des fichiers au-delà d’un seul serveur
Le stockage disque de Multer écrit sur le système de fichiers local. C’est bien pour le développement, mais dès que vous avez deux instances de votre serveur (ou redémarrez un conteneur), les fichiers sont perdus ou inaccessibles.
Vous avez besoin de stockage cloud. Vous ajoutez l’AWS SDK, configurez les credentials S3, passez Multer en stockage mémoire (bufferiser tout le fichier en RAM avant l’upload — mauvais pour les fichiers volumineux), ou streamez directement vers S3 avec multer-s3. Vous ajoutez ainsi une autre couche de credentials, de politiques IAM et de modes de défaillance.
La validation du type de fichier qui fonctionne vraiment
Vérifier file.mimetype depuis Multer est quasiment inutile pour la sécurité. Le type MIME vient du client et peut être falsifié trivialement. Vous devez inspecter les octets réels du fichier — les nombres magiques — pour déterminer le type de fichier réellement uploadé.
import { fileTypeFromBuffer } from "file-type";
// Après avoir reçu le fichier en mémoire
const fileBuffer = req.file.buffer;
const detectedType = await fileTypeFromBuffer(fileBuffer);
if (!detectedType || !["image/jpeg", "image/png", "image/webp"].includes(detectedType.mime)) {
return res.status(400).json({ error: "Type de fichier invalide" });
}
Vous bufferisez maintenant le fichier entier en mémoire uniquement pour vérifier son type, ce qui plafonne les tailles de fichiers que vous pouvez gérer en toute sécurité.
La progression WebSocket pour les pipelines multi-étapes
Quand le traitement implique plusieurs étapes (upload → scan antivirus → redimensionnement → conversion → stockage → notification), les utilisateurs veulent savoir où ils en sont dans le pipeline. Pas seulement « uploadé à 50% » mais « étape 3 sur 5 : redimensionnement en cours ». Cela nécessite une communication serveur-vers-client en temps réel — des WebSockets — et un état qui survit entre la requête HTTP d’upload et la connexion WebSocket.
À ce stade, vous avez : Express, Multer, Sharp, BullMQ, Redis, l’AWS SDK, file-type, et une bibliothèque WebSocket. Vous avez écrit une logique d’upload reprises personnalisée, un système de file de jobs, une récupération d’erreurs multi-étapes et une couche de notification en temps réel. Votre « fonctionnalité » d’upload est maintenant un petit projet d’infrastructure.
Ce qu’Uploadista fait différemment
Uploadista est construit sur la conviction que toute cette complexité devrait être prise en charge par le framework, pas réimplémentée par chaque équipe qui a besoin de gérer des fichiers.
Voici à quoi ressemble le même serveur Express avec le package @uploadista/adapters-express :
npm install express @uploadista/server @uploadista/adapters-express \
@uploadista/data-store-filesystem @uploadista/kv-store-filesystem \
@uploadista/flow-images-sharp ws cors
import { createServer } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createExpressUploadistaAdapter } from "@uploadista/adapters-express";
import { createFileStore } from "@uploadista/data-store-filesystem";
import { fileKvStore } from "@uploadista/kv-store-filesystem";
import { imagePlugin } from "@uploadista/flow-images-sharp";
import { createFlow, createInputNode, createStorageNode } from "@uploadista/core";
import cors from "cors";
import express from "express";
import { WebSocketServer } from "ws";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function startServer() {
const app = express();
const server = createServer(app);
app.use(cors());
app.use((req, res, next) => {
if (req.path.startsWith("/uploadista/")) return next();
express.json()(req, res, next);
});
// Stockage : remplacez par S3, GCS ou Azure en production
const dataStore = createFileStore({
directory: join(__dirname, "../uploads"),
deliveryUrl: "http://localhost:3000",
});
// KV store pour l'état des sessions d'upload
const kvStore = fileKvStore({
directory: join(__dirname, "../uploads"),
});
// Définir un flux : entrée → redimensionnement → stockage
const flows = (_flowId: string) =>
createFlow({
flowId: "image-flow",
name: "Flux de traitement d'images",
nodes: {
input: createInputNode("input"),
output: createStorageNode("output"),
},
edges: [{ source: "input", target: "output" }],
});
// Créer l'adapter — c'est l'intégralité du serveur d'upload
const uploadistaAdapter = await createExpressUploadistaAdapter({
kvStore,
dataStore,
flows,
plugins: [imagePlugin()],
});
app.get("/health", (_req, res) => res.json({ status: "OK" }));
// Tous les endpoints d'upload gérés ici
app.all("/uploadista/api/*splat", uploadistaAdapter.handler);
// WebSocket pour la progression en temps réel
const wss = new WebSocketServer({ server });
wss.on("connection", uploadistaAdapter.websocketConnectionHandler);
server.listen(3000, () => {
console.log("🚀 Serveur démarré sur http://localhost:3000");
console.log("📁 Endpoint d'upload : http://localhost:3000/uploadista/api/");
console.log("🔌 Endpoint WebSocket : ws://localhost:3000/uploadista/ws/");
});
}
startServer();
Ce n’est pas une démo simplifiée. C’est un serveur complet avec uploads reprises par morceaux, progression en temps réel via WebSocket, traitement modulaire et stockage interchangeable — prêt à fonctionner.
Ce que vous obtenez d’emblée
Uploads reprises. Le protocole tus est implémenté au niveau du framework. Les clients peuvent mettre en pause et reprendre depuis n’importe quel offset. Les connexions interrompues reprennent là où elles s’étaient arrêtées.
Progression en temps réel via WebSocket. Connectez-vous à ws://votre-serveur/uploadista/ws/upload/:uploadId et recevez des événements de progression tout au long de l’upload et du pipeline de traitement. Pas de polling, pas d’infrastructure WebSocket personnalisée.
Traitement orienté flux avec pipelines typés. Les étapes de traitement sont définies comme des nœuds dans un graphe de flux. Chaque nœud a des entrées et sorties typées — si vous connectez un nœud d’image à une étape qui attend de la vidéo, TypeScript le détecte à la compilation, pas à 3h du matin en production.
Backends de stockage modulaires. Remplacez createFileStore par s3Store, gcsStore ou azureStore sans rien changer d’autre. En production, pointer vers votre propre bucket S3 est une modification d’une ligne :
import { s3Store } from "@uploadista/data-store-s3";
const dataStore = s3Store({
deliveryUrl: process.env.S3_DELIVERY_URL,
s3ClientConfig: {
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
},
});
KV stores de niveau production. Passez du store filesystem à Redis pour les déploiements multi-instances :
import { redisKvStore } from "@uploadista/kv-store-redis";
import { createClient } from "@redis/client";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const kvStore = redisKvStore({ redis });
Pipelines multi-étapes avec sémantique de retry. Si l’étape 3 d’un flux en 5 étapes échoue, seule l’étape 3 est relancée lors d’une nouvelle tentative. Pas de fichiers orphelins. Pas de nettoyage manuel. Le framework suit l’état à travers chaque nœud du graphe.
Plugins de sécurité. Ajoutez le scan antivirus à n’importe quel flux avec @uploadista/flow-security-clamscan. La validation du type de fichier basée sur les nombres magiques est intégrée nativement, pas greffée après coup.
Aller plus loin : un vrai flux de traitement d’images
Voici un flux plus complet qui couvre ce dont la plupart des applications à forte composante image ont besoin — redimensionnement, optimisation et stockage dans des formats parallèles :
import {
createFlow,
createInputNode,
createStorageNode,
} from "@uploadista/core";
import {
createResizeNode,
createOptimizeNode,
} from "@uploadista/flow-images-nodes";
const imageFlow = createFlow({
flowId: "image-pipeline",
name: "Pipeline d'images",
nodes: {
input: createInputNode("input"),
resize: createResizeNode("resize", {
width: 1200,
height: 800,
fit: "cover",
}),
optimize: createOptimizeNode("optimize", {
quality: 80,
format: "webp",
}),
output: createStorageNode("output"),
},
edges: [
{ source: "input", target: "resize" },
{ source: "resize", target: "optimize" },
{ source: "optimize", target: "output" },
],
});
Chaque arête est typée. La sortie de createResizeNode correspond à l’entrée attendue par createOptimizeNode. Le compilateur impose le contrat. Vous ne découvrez pas les incompatibilités à l’exécution.
Le même framework, n’importe quel serveur Node.js
Express n’est qu’une option. Le même pattern d’adapter fonctionne avec Hono et Fastify si vous préférez leur ergonomie :
// Hono
import { createHonoUploadistaAdapter } from "@uploadista/adapters-hono";
const uploadistaAdapter = await createHonoUploadistaAdapter({ kvStore, dataStore, flows, plugins });
// Fastify
import { createFastifyUploadistaAdapter } from "@uploadista/adapters-fastify";
const uploadistaAdapter = await createFastifyUploadistaAdapter({ kvStore, dataStore, flows, plugins });
La configuration est identique. Le protocole d’upload sous-jacent et le système de flux sont identiques. Changer de framework est une modification d’une ligne.
Essayez par vous-même
Si vous démarrez un nouveau projet, le chemin le plus rapide vers un serveur d’upload prêt pour la production est :
mkdir mon-serveur-upload && cd mon-serveur-upload
npm init -y
npm install express @uploadista/server @uploadista/adapters-express \
@uploadista/data-store-filesystem @uploadista/kv-store-filesystem \
@uploadista/flow-images-sharp ws cors
Copiez l’exemple de serveur ci-dessus, lancez npx ts-node src/server.ts, et vous avez un serveur d’upload fonctionnel avec uploads reprises par morceaux, progression en temps réel via WebSocket et traitement d’images.
La documentation complète couvre chaque backend de stockage, option de KV store, plugin de traitement et pattern de déploiement. Le SDK est sous licence MIT sur GitHub — lisez le code source, contribuez ou forkez.
Et si vous migrez un setup Multer existant, le processus est simple : remplacez le handler de la route d’upload par uploadistaAdapter.handler, ajoutez le serveur WebSocket, et définissez vos flux. Votre logique de stockage et de traitement existante peut migrer vers des nœuds de flux progressivement.
Les parties complexes de l’upload de fichiers — reprises, suivi de progression, orchestration de pipeline, récupération d’erreurs — sont des problèmes résolus. Vous ne devriez pas avoir à les résoudre à nouveau depuis zéro.
Denis est le fondateur et CEO d’Uploadista. Si vous avez des questions sur la migration depuis Multer ou la conception d’un pipeline de traitement de fichiers, retrouvez-le sur GitHub ou Twitter/X.