Next.js : Comment streamer des fichiers avec les Route Handlers

|

Image de couverture montrant un fichier streamé

J'ai essayé de rédiger une introduction accrocheuse, expliquant pourquoi les fichiers sont si importants dans la vie d'un développeur et pourquoi vous devriez absolument lire cet article.

Mais à moins que vous ne veniez de sortir d'une machine à remonter le temps, je suis sûr que vous savez déjà ce qu'est un fichier, que les fichiers sont importants pour les ordinateurs et que les fichiers peuvent être volumineux.

Alors passons à l'essentiel et répondons directement à la question qui vous préoccupe : comment streamer des fichiers à partir des Route Handlers de Next.js, pour permettre aux utilisateurs de les télécharger.

Vous apprendrez :

  • Comment servir de gros fichiers avec Next.js sans surcharger votre RAM, en utilisant les API Routes et les Route Handlers

  • Ce que sont les streams (flux) dans Node.js et en JavaScript générique

  • Les différences philosophiques entre les API Routes et les Route Handlers nouvellement introduits par le App Router

Cet article est long, il sera meilleur accompagné d'une tasse de thé ou de café ! Il cible plutôt les développeurs backend. 🇬🇧 Speak English? This article is a translation of my blog post "How to stream files from Next.js Route Handlers"

Pourquoi utiliser un point d'entrée d'API pour servir des fichiers, plutôt que le dossier "public" ? 

Dans les applications Node.js, on utilise traditionnellement un dossier "public" pour servir des fichiers, tels que des images, des polices d'écriture, des scripts tiers ou la dernière version de votre CV au format PDF.

Dans Express.js, le dossier public est configuré en une seule ligne de code (documentée ici) :

// Le fichier "/public/cv.pdf" devient accessible sur l'URL "/cv.pdf"
app.use(express.static('public'))

Dans Next.js, cela se fait en 0 lignes de code, le dossier public est déjà configuré.

Cependant, cela ne fonctionne que pour les fichiers publics et génériques. Next.js les appelle des "ressources statiques" (static assets). Cependant, certains fichiers ne sont ni publics, ni génériques ni statiques.

Voici quelques contre-exemples qui ne fonctionnent pas avec le dossier public :

  • Fichiers personnalisés : une URL générique "/user/profile-picture" peut servir une image de profil différente en fonction de l'utilisateur actuellement connecté.
  • Fichiers privés : vous pouvez vouloir vérifier certaines permissions avant de servir "rapport_super_confidentiel.docx".
  • Fichiers générés : l'application d'administration de l'enquête State of JavaScript peut générer des exports CSV des réponses au sondage. Cela signifie que nous générons le fichier à la volée en fonction du sondage sélectionné lorsque nous recevons une demande d'exportation.

Pour ces fichiers, vous devez créer un point d'entrée d'API qui renvoie le contenu du fichier. 

Faisons cela.

Méthode 1 : servir des fichiers dans Next.js avec des Route Handlers, la manière simple 

La manière simple de servir des fichiers est de les charger en mémoire, puis de retourner le résultat. Voici le code complet que vous pouvez copier-coller pour créer un nouveau route handler.

// Fichier "app/api/serve-file/route.ts" 
// Il s'agit d'un Route Handler (Next.js 13+ avec le App Router)

// fs/promises permet d'avoir une syntaxe async/await
import fsPromises from "fs/promises" 

// Le type "Request" n'est pas importé,
// il vient de la plateforme web (via fetch)
// mais existe aussi en Node.js
export const GET = (req: Request) => {
  // le code est simplifié, il faudrait vérifier que le fichier existe
  const filePath = "/tmp/some-file.zip"
  const stats = await fsPromises.stat(filePath);
  // lecture du fichier => il est chargé en RAM
  const fileContent = await fsPromises.readFile(filePath)
  // et on renvoit un réponse HTTP
  return new Response(
      fileContent, 
      {
        status: 200,
        headers: new Headers({
        // cette en-tête déclenche un téléchargement dans le navigateur
          "content-disposition": `attachment; filename=${
            path.basename(filePath)
            }`,
          "content-type": "application/zip",
          "content-length": stats.size + "",
      })
    })
}

Parfois, au lieu de Request et Response, vous verrez NextResponse et NextRequest. Les versions Next ne sont que des abstractions autour des objets Response et Request natifs. Ils fournissent quelques aides telles que l'URL parsée dans le champ nextUrl.

C'est une solution intuitive, totalement acceptable si vous opérez à petite échelle.

Cependant, le problème est que vous devez charger l'intégralité du fichier en mémoire avant de le renvoyer. Ce n'est pas bon si le fichier est très volumineux. Même si le fichier est petit, si vous avez un grand nombre de requêtes, cela saturera également votre RAM.

// cette variable "data" coûte cher pour les gros fichiers!
// le fichier est copié entièrement dans la mémoire vive
const data = await fsPromises.readFile(filePath)

La solution à cette limitation consiste à streamer le fichier. Il sera lu par morceaux (chunks) qui sont immédiatement envoyés à l'utilisateur final, ce qui réduit la pression sur la RAM de votre serveur.

MISE A JOUR 2024 - Méthode 2 : créer un stream web en une seule ligne de code

J'ai récemment découvert une nouvelle API (gràce à Karl Horky), filehandle.readableWebStream.

Il s'agit d'une fonctionnalité expérimentale de Node.js qui résout notre problème en une seule ligne:

const fileHandle = await fs.open(filePath)
const stream = fileHandle.readableWebStream({ type: "bytes" })
return new Response(stream)

Mon nouveau cours NextPatterns fournit une démonstration live de ce pattern, et bien d'autres encore !

Méthode 3 : streamer des fichiers lourds avec les API Routes (Next.js 9+) 

Commençons par la solution traditionnelle, en utilisant les API Routes de Next.js, disponibles depuis la version 0 dans le routeur historique, aujourd'hui appelé "Page Router" et situé dans le dossier "pages" (par opposition au nouveau "App Router" situé dans le dossier "app"). 

Voici comment streamer un fichier depuis une API Routes, sans le copier en mémoire. Cela ressemble beaucoup à ce que vous pourriez trouver dans une application Express.

// Fichier "pages/api/serve-file.ts" 
// C'est une "API Route"
import { NextApiRequest, NextApiResponse } from 'next'

export default async function serveFile(
  req: NextApiRequest, res: NextApiResponse){
  const filePath = "/tmp/some-file.zip";
  const stats = await fsPromises.stat(filePath);
    res.writeHead(200, {
      "Content-Disposition": 
      `attachment; filename=${path.basename(
        filePath
      )}`,
      "Content-Type": "application/zip",
      "Content-Length": stats.size,
    });
    await new Promise(function (resolve) {
      const nodeStream = fs.createReadStream(filePath);
      nodeStream.pipe(res);
      nodeStream.on("end", resolve);
    });
}

NextApiRequest/NextApiResponse sont destinés aux API Routes (Next 9+), tandis que NextRequest et NextResponse sont destinés aux Route Handlers (Next 13+). Ce sont des choses totalement différentes et pas directement compatibles ! Nous expliquerons cette différence dans la section suivante.

Passer aux route handlers : ce n'est pas si simple 

Maintenant, imaginez que nous voulions passer aux Route Handlers, la nouvelle manière de créer des points d'entrée d'API introduite par le App Router de Next.js.

Malheureusement, nous ne pouvons pas simplement copier-coller le code que nous avons écrit pour une API Route dans un Route Handler.

Le problème est que le flux attendu par les route handlers, ou plus précisément par le constructeur Response, est un stream de la "plateforme web". Il est totalement différent de la structure ReadStream que nous utilisons dans les API Routes, qui est définie par le package fs de Node.js !

Vous ne pouvez pas simplement passer le résultat de fs.createReadStream à un objet Response. Cela signifie que nous avons besoin d'une étape de conversion supplémentaire.

// Créer "ReadStream" Node.js
const nodeStream = fs.createReadStream(filePath);
  // et le passer à la réponse, qui accepte un objet "ReadableStream"...
  return new Response(
      // ❌ ... ne fonctionne pas!
      // Ce ne sont pas les mêmes "Stream" !
      nodeStream, 

C'est déconcertant, un stream est un stream, alors pourquoi cela ne fonctionne pas ?

Les route handlers et les API routes reposent sur des paradigmes différents 

Les nouveaux route handlers diffèrent assez largement des API routes traditionnelles.

Les API routes de Next.js, qui existent depuis la version 9 et resteront probablement autour indéfiniment, sont basées sur Node.js. Elles sont cousines d'Express, c'est pourquoi vous pouvez facilement utiliser des middlewares et des bibliothèques Express, comme Passport pour l'authentification, dans Next.js.

Les route handlers sont des nouveaux venus introduits par la version 13. La principale différence avec les API routes est qu'ils ne sont pas strictement liées à Node.js. 

Ils utiliseront toujours Node comme runtime par défaut, mais vous pouvez optionnellement utiliser une alternative plus légère appelée "Edge Runtime". 

Plus largement, cette approche favorise l'utilisation de structures de données JavaScript qui devraient exister dans n'importe quel runtime, que ce soit Node.js, le runtime Edge, Deno, Bun ou le runtime que vous êtes peut-être en train de construire dans votre garage car oui, non seulement on aime beaucoup les frameworks en JavaScript, mais maintenant on aime aussi les runtimes !

C'est pourquoi les route handlers de Next.js utilisent des objets Response et Request génériques, qui ont été initialement conçus pour l'API fetch du navigateur.

De plus, les handlers utilisent une approche différente pour "répondre" à la requête HTTP de l'utilisateur. Dans une route API, vous appelez res.send, ou vous pipez un flux vers l'objet res. Vous n'avez pas besoin de retourner quoi que ce soit. Dans un route handler, vous devez retourner un objet Response. Ensuite, Next.js se charge de le transformer en une réponse HTTP.

Voici à quoi ressemblera le code dans chaque paradigme :

// Renvoyer du JSON dans une API Route :
res.send({foo: "bar"})

// Streamer un fichier dans une API Route :
// (la réponse HTTP est envoyée via l'appel à pipe)
const nodeStream = fs.createReadStream(filePath);
nodeStream.pipe(res);

// Renvoyer du JSON dans un route handler
// (en utilisant un helper de Next pour renvoyer du JSON facilement)
return NextResponse.json({foo:"bar") 

// Streaming dans un Route Handler: on renvoie un objet Response
// et Next.js se charge de générerer la réponse HTTP
// streamFile est la fonction que nous allons écrire dans cet article
const webPlatformStream = streamFile(filePath)
return Response(webPlatformStream) // le "return" est obligatoire

Je tiens à mentionner que vous n'êtes pas du tout obligé d'utiliser des Route Handlers si vous ne le souhaitez pas !

Les API Routes fonctionnent très bien et fonctionneront très bien dans un avenir prévisible. Les fonctionnalités apportées par le App Router Next 13+ sont formidables, mais elles sont aussi pointues et difficiles à utiliser. Il est tout à fait sûr d'attendre un an, voire plus, avant même de commencer à les utiliser.

Maintenant, nous comprenons pourquoi nous avons 2 approches différentes. Les nouveaux Route Handlers visent à être plus génériques que les routes API, qui sont spécifiques à Node.js.

Cependant, si nous voulons vraiment un route handler, cela signifie que nous devons apprendre à convertir des structures spécifiques à Node en structures génériques de la plateforme web, et cela nécessite un peu de travail. 

Voyons comment faire pour streamer des fichiers.

Méthode 4 (la plus difficile) : convertir les streams Node.js en streams web via des générateurs

L'objectif est toujours de streamer un fichier pour que l'utilisateur puisse le télécharger, sans avoir à le copier dans la mémoire RAM de notre serveur.

Notre problème est que nous devons convertir un fs.ReadStream de Node.js en un ReadableStream de la plateforme web. Nous devons également prendre en compte les différences de syntaxe entre les deux mondes.

Quand j'ai creusé cette question, il n'y avait pas de solution documentée, donc j'ai dû en créer une. 

Plus précisément, j'ai assemblé divers morceaux de code trouvés sur le web, en mode Frankenstein. Vous pouvez lire les détails techniques sur Stack Overflow.

Les générateurs sont une structure de données générique en JavaScript, donc il devrait être possible de les utiliser comme langage intermédiaire entre Node et la plateforme web pour représenter un fichier en plusieurs morceaux.

Je vais utiliser les termes générateur et itérateur de manière interchangeable. Formellement, vous utilisez une fonction générateur pour produire un itérateur. Plus de détails dans la documentation Mozilla Developer.

Voici le pseudo-code que nous voulons implémenter :

  1. Convertir un fs.ReadStream (fourni par Node.js quand on ouvre un fichier) en un itérateur JavaScript.
  2. Convertir l'itérateur JavaScript en ReadableStream (attendu par les route handlers de Next.js).

Si cela semble difficile, c'est parce que c'est difficile. 

Nous essayons d'établir un lien entre deux mondes complètement différents, le monde de Node.js et le monde de la plateforme web. Quand on sort les générateurs c'est rarement pour blaguer !

Etape 1: conversion de fs.ReadStream vers un itérateur

Voici une fonction générateur qui retourne un itérateur sur un fs.ReadStream :

// Syntaxe reprise de :
// https://github.com/MattMorgis/async-stream-generator
// Qui l'avait reprise de :
// https://nextjs.org/docs/app/building-your-application/routing/router-handlers#streaming
// Qui l'a probablement reprise de :
// https://nodejs.org/api/stream.html
// Citez toujours vos sources, même quand c'est difficile !
async function* nodeStreamToIterator(stream: fs.ReadStream) {
    for await (const chunk of stream) {
        yield new Uint8Array(chunk);
    }
}

La partie const chunk of stream lit le fichier un morceau à la fois. La partie await est là simplement parce que la lecture d'un morceau d'un fichier est une opération asynchrone, tout comme la lecture du fichier entier. Cela est possible car les flux de Node.js sont des itérateurs asynchrones, donc cette syntaxe fonctionne avec eux.

Vous n'êtes peut-être pas habitué aux itérateurs, mais en gros, pensez-y comme à des tableaux de taille arbitraire (même infinie) que vous pouvez lire valeur par valeur. C'est pourquoi on "yield" un morceau de données, au lieu de retourner le fichier entier.

L'appel à Uint8Array est nécessaire pour que les données aient le bon encodage. Je sers des fichiers binaires ici, pour les fichiers texte vous voudrez peut-être utiliser TextEncoder à la place. Voir ce ticket GitHub pour plus d'informations.

En Node.js, les flux sont principalement destinés à être consommés via des événements attachés à l'objet stream. Selon la documentation de Node.js, définir un gestionnaire d'événements "data" sur un stream va déclencher son "flot". Vous pouvez également utiliser la méthode pipe comme montré précédemment. Cela explique pourquoi la version avec itérateur est moins courante dans la nature, cependant elle est mieux adaptée à notre objectif.

Etape 2: d'un itérateur vers un ReadableStream

Maintenant que nous avons un itérateur, la documentation de Mozilla nous montre comment le convertir en un ReadableStream de la plateforme web :

// Fourni par la documentation Mozilla Developer
// https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#convert_async_iterator_to_stream
// Il faut toujours remercier Mozilla !
function iteratorToStream(iterator) {
  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next();
      if (done) {
        controller.close();
      } else {
        controller.enqueue(value);
      }
    },
  });
}

Etape presque finale : brancher les fonctions ensemble !

Nous pouvons donc convertir un flux Node.js en un itérateur, et un itérateur en un ReadableStream de la plateforme web.

Nous sommes prêts à créer notre fonction "streamFile", utilisable dans les route handlers pour streamer un fichier sans le charger en mémoire !

// On peut enfin aller du chemin du fichier,
// jusqu'à un ReadableStream compatible avec les Route Handlers !
export function streamFile(path: string): ReadableStream {
    const nodeStream = fs.createReadStream(path);
    const data: ReadableStream = iteratorToStream(
        nodeStreamToIterator(
            nodeStream
        )
    )
    return data
}

Vous remarquerez peut-être que createReadStream est une fonction de Node.js, donc ce code fonctionne uniquement dans Node.js malgré le fait qu'il retourne un ReadableStream générique. Il n'est pas compatible avec le runtime Edge (par contre Deno supporte de mieux en mieux les modules Node.js).

C'est tout à fait normal. Les route handlers sont censés être compatibles avec différents runtimes, cependant vous devez toujours écrire du code spécifique au runtime chaque fois que vous voulez effectuer une opération non triviale. Si vous utilisez Deno, vous aurez du code spécifique à Deno.

Idéalement, JavaScript pourrait fournir une fonction générique pour ouvrir des fichiers, qui renverrait un ReadableStream par défaut. Cependant, je ne suis pas au courant d'une telle API. Contactez-moi sur X si vous avez plus d'informations que moi !

Etape vraiment finale : servir le fichier en utilisant un Route Handler

Nous sommes allés si loin dans le JavaScript avancé que nous avons presque oublié l'objectif initial : nous voulions simplement streamer un fichier dans un route handler, pour permettre à l'utilisateur final de télécharger quelque chose !

Obtenir un ReadableStream a été la partie la plus difficile, maintenant nous devons simplement le passer à l'objet Response :

 // Fichier "app/api/serve-file/route.ts"
 // Il s'agit du code final,
 // qui réutilise notre helper "streamFile"
 // décrit plus haut dans l'article
 // (oui il faut lire tout l'article désolé !!)
 const stats = await fsPromises.stat(filePath);
 const stream: ReadableStream = streamFile(filePath)
 return new Response(stream, {
   status: 200,
   headers: new Headers({
    "content-disposition": 
    `attachment; filename=${path.basename(
       filePath
     )}`,
    "content-type": "application/zip",
    "content-length": stats.size + "",
 })

Vous pouvez voir un exemple d'utilisation réelle dans la base de code de State of JavaScript, pour laisser l'utilisateur télécharger un fichier zip CSV+JSON à partir d'une base de données Mongo.

Récap et conclusion

Essayons de récapituler ce que nous avons appris dans cet article :

  • Servir des fichiers via une API est nécessaire pour les fichiers personnalisés, privés ou générés. 

  • Utiliser un flux est nécessaire pour éviter de surcharger la RAM de votre serveur, cela évite de charger le fichier entier en mémoire et le retourne plutôt par morceaux.

  • Les API routes de Next.js utilisent la syntaxe et les structures de données de Node.js, il est facile de diffuser un fichier.

  • Les route handlers de Next.js s'efforcent de s'appuyer sur des structures JavaScript intégrées plus génériques, à savoir les objets Request et Response de l'API fetch.

  • Nous avons dû créer un helper pour convertir le flux produit par fs.createReadStream(filePath), qui est spécifique à Node.js, en un ReadableStream générique.

  • Enfin, nous pouvons passer le flux converti à l'objet Response. Tada ! 

Merci Rohit Handique pour les retours sur le GitHub de Next.js et à toutes les personnes dont j'ai dû copier-coller et fusionner le code pour produire cette merveille.

Bonus

J'ai également essayé de convertir un objet Request (natif du web) en NextAPIRequest (Node.js), afin de pouvoir réutiliser la logique Express/Connect dans les route handlers, ce qui permettrait notamment d'utiliser Passport. Si cela vous intéresse, vous pouvez contribuer à cette issue GitHub sur le projet next-connect.   Si vous voulez en savoir plus sur les streams dans Next.js, je vous recommande cet article de Mohammad (Minimalist Web Dev). Il explique comment les streams sont utilisés par React Server Components pour fournir du HTML de manière progressive.


Quelques ressources pour approfondir

Vous avez aimé cet article? Vous aimerez peut-être mes formations Next.js. Je suis un ingénieur indépendant avec plusieurs années d'expérience dans la conception d'architectures frontend scalables, et j'aime par dessus tout partager les connaissances acquises au cours de mes expériences et mes recherches.

Découvrez ma formation Next.js en 3 jours Une introduction à Next.js pour bien saisir les enjeux du développement web moderne hybridant les logiques client et serveur.

Formation Next.js en 3 jours

https://www.formationnextjs.fr/

Mes masterclass en 1 journée sur des sujets spécifiques

Vous codez déjà avec Next.js ? Allons plus loin avec des cours avancés dédiés à des problématiques techniques complexes.

Une playlist Youtube où l'on suit le tutoriel Next.js Learn, en français

Le tuto Next.js Learn en français sur YoutTube

Vous êtes plutôt vidéo ? Voici une petite playlist où l'on suit le tutoriel officiel Next.js Learn (contenu gratuit et en français):

Mes cours vidéos (en anglais)

Newline cover

Next.js Patterns