Aller au contenu
Toutes les notes

4 min de lecture

GridFS depuis Bun, sans cérémonie

Pourquoi j'ai écrit bun-gridfs-storage, ce que l'ancien moteur Multer faisait mal sous Bun, et les quatre décisions qui ont rendu les uploads ennuyeux à nouveau.

Chaque produit que j'ai livré ces quatre dernières années stocke des fichiers quelque part, et chacun a commencé par la même conversation : stockage objet, ou la base que nous faisons déjà tourner ? Pour Keforo, la réponse a été MongoDB. Les fichiers étaient petits, ils appartenaient à des documents qui vivaient déjà là, et une seule sauvegarde couvrait tout. Cette décision était facile. Faire fonctionner les uploads sous Bun ne l'était pas.

#Ce qui a cassé

L'écosystème Node a une réponse à cela : multer analyse le corps multipart, et un moteur de stockage décide où va chaque fichier. multer-gridfs-storage était ce moteur pour MongoDB, avec des années de production derrière lui.

Sous Bun, il tombait à deux endroits. Sa gestion des flux supposait des internes de Node que l'implémentation de Bun ne respecte pas, et son câblage d'EventEmitter émettait avant que les écouteurs soient attachés. Les uploads restaient suspendus, ou se résolvaient avec un enregistrement de fichier qui ne pointait vers rien. Le paquet n'avait pas connu de version depuis longtemps, et je ne voulais pas d'un fork avec des correctifs que personne d'autre n'exécuterait.

J'ai donc écrit un moteur de stockage qui fait exactement une chose et la fait de la même façon sur les deux runtimes. C'est devenu bun-gridfs-storage.

#Ce qu'un moteur de stockage doit vraiment faire

Le contrat de Multer est petit. Un moteur reçoit la requête et le flux du fichier entrant, l'écrit quelque part, puis rappelle avec ce qui doit se retrouver sur req.file. Pour GridFS, cela veut dire ouvrir un flux d'upload sur un bucket et y diriger le flux.

upload.tstypescript
import { BunGridFSStorage } from 'bun-gridfs-storage'
import multer from 'multer'
import mongoose from 'mongoose'

await mongoose.connect(process.env.MONGO_URL!)

const storage = new BunGridFSStorage({
  db: mongoose.connection.db,
  file: (req, file) => ({
    filename: `${Date.now()}-${file.originalname}`,
    bucketName: 'uploads',
  }),
})

const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } })

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ file: req.file })
})

Tout le reste du paquet existe pour rendre cela ennuyeux en production. Quatre décisions ont fait l'essentiel du travail.

#1. La base peut arriver en retard

Le moteur de stockage est généralement construit au chargement du module, bien avant que la connexion soit ouverte. Plutôt que d'imposer un ordre de démarrage, db accepte soit un Db, soit une Promise<Db>. Les uploads qui arrivent avant que la promesse soit résolue l'attendent.

typescript
const storage = new BunGridFSStorage({
  db: getDb(),
  file: (req, file) => ({ filename: file.originalname, bucketName: 'uploads' }),
})

async function getDb() {
  if (mongoose.connection.readyState === 1) return mongoose.connection.db
  return new Promise((resolve, reject) => {
    mongoose.connection.once('open', () => resolve(mongoose.connection.db))
    mongoose.connection.once('error', reject)
  })
}

#2. Un seul rappel décide de tout pour un fichier

Nom de fichier, bucket, taille de chunk, type de contenu et métadonnées viennent tous du même rappel file, qui peut être asynchrone. C'est là qu'une vraie application met l'identifiant de l'utilisateur, le tenant, ou un hash de contenu calculé ailleurs.

typescript
const storage = new BunGridFSStorage({
  db: mongoose.connection.db,
  file: async (req, file) => ({
    filename: `${crypto.randomUUID()}-${file.originalname}`,
    bucketName: 'uploads',
    chunkSize: 255 * 1024,
    contentType: file.mimetype,
    metadata: {
      userId: req.user.id,
      originalName: file.originalname,
    },
  }),
})

#3. Des événements, pas des logs

Le moteur étend EventEmitter et émet connection, file, streamError et connectionFailed. Cela semble anodin jusqu'au jour où il faut compter les uploads échoués par tenant, ou lancer une analyse antivirus à l'instant où un fichier arrive, sans toucher au gestionnaire de route.

typescript
storage.on('file', (file) => metrics.increment('uploads', { bucket: file.bucketName }))
storage.on('streamError', (error, config) => log.error({ error, config }, 'upload failed'))

#4. Bun d'abord, Node quand même

Les tests tournent sous bun test, et le build livre CommonJS et ESM. Node 18 et suivants fonctionnent sans changement. Je ne veux pas de deux histoires d'upload dans une même base de code parce qu'un service tourne sur un autre runtime.

#Relire les fichiers

Téléchargements et suppressions passent directement par le bucket du driver. Le moteur l'expose pour que vous n'en construisiez jamais un second.

typescript
app.get('/files/:name', (req, res) => {
  const bucket = storage.getBucket()
  if (!bucket) return res.status(503).send('storage not ready')
  bucket
    .openDownloadStreamByName(req.params.name)
    .on('error', () => res.status(404).end())
    .pipe(res)
})

#Quand GridFS est la mauvaise réponse

Si vos fichiers sont volumineux, publics ou servis à de nombreux lecteurs, un CDN devant un stockage objet fera mieux à chaque fois, et pour moins cher. GridFS mérite sa place quand les fichiers sont privés, de taille modeste, liés à des documents que vous interrogez déjà, et que vous voulez une seule sauvegarde, un seul modèle de contrôle d'accès et une seule chaîne de connexion. C'était le cas de chaque upload Keforo, et c'est pour cela que le paquet existe.

Ça vous a parlé ?

Conversation

Vous construisez quelque chose de ce genre ?

Dites-moi sur quoi vous travaillez. Je réponds sous un jour.

M'écrire