31 octobre 2022

Upload pouvant être repris

Avec la méthode fetch, il est assez facile de upload un fichier.

Comment reprendre l’upload après une perte de connexion ? Il n’y a pas d’option intégrée pour cela, mais nous avons les pièces pour l’implémenter.

Les uploads pouvant être repris devraient être accompagnés d’une indication de progression, puisque nous pouvons nous attendre à de gros fichiers (au cas où on devrait reprendre). Donc, comme fetch ne permet pas de suivre la progression du téléchargement, nous utiliserons XMLHttpRequest.

Événement de progression pas si utile

Pour reprendre l’upload, nous devons savoir combien a été uploadé jusqu’à ce que la connexion soit perdue.

Il y a xhr.upload.onprogress pour suivre la progression de l’upload.

Malheureusement, cela ne nous aidera pas à reprendre l’upload ici, car cela ne se déclenche que lorsque les données sont envoyées, mais est-ce que le serveur l’a reçu ? Le navigateur ne sait pas.

Peut-être que cela a été mis en mémoire tampon par un proxy de réseau local, ou peut-être que le processus du serveur distant vient de mourir et n’a pas pu les traiter, ou cela a juste été perdu au milieu et n’a pas atteint le destinataire.

C’est pourquoi cet événement n’est utile que pour afficher une belle barre de progression.

Pour reprendre l’upload, nous devons connaître exactement le nombre d’octets reçus par le serveur. Et seul le serveur peut le dire, nous ferons donc une demande supplémentaire.

Algorithme

  1. Créer d’abord un identifiant de fichier pour identifier de manière unique le fichier que nous allons uploader :

    let fileId = file.name + '-' + file.size + '-' + file.lastModified;

    Cela est nécessaire pour reprendre l’upload, pour indiquer au serveur ce que nous reprenons.

    Si le nom ou la taille ou la dernière date de modification change, alors il y aura un autre fileId.

  2. Envoyer une demande au serveur, lui demandant combien d’octets il possède déjà, comme ceci :

    let response = await fetch('status', {
      headers: {
        'X-File-Id': fileId
      }
    });
    
    // Le serveur a autant d'octets
    let startByte = +await response.text();

    Cela suppose que le serveur effectue le suivi des uploads de fichiers par l’en-tête X-File-Id. Doit être implémenté côté serveur.

    Si le fichier n’existe pas encore sur le serveur, la réponse du serveur doit être 0.

  3. Ensuite, nous pouvons utiliser un Blob par la méhtode slice pour envoyer le fichier depuis startByte :

    xhr.open("POST", "upload");
    
    // Identifiant du fichier, afin que le serveur sache quel fichier nous uploadons
    xhr.setRequestHeader('X-File-Id', fileId);
    
    // L'octet à partir duquel nous reprenons, donc le serveur sait que nous reprenons
    xhr.setRequestHeader('X-Start-Byte', startByte);
    
    xhr.upload.onprogress = (e) => {
      console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    };
    
    // le fichier peut provenir de input.files[0] ou d'une autre source
    xhr.send(file.slice(startByte));

    Ici, nous envoyons au serveur à la fois l’ID du fichier en tant que X-File-Id, afin qu’il sache quel fichier nous uploadons, et l’octet de départ en tant que X-Start-Byte, afin qu’il sache que nous ne l’uploadons pas de zéro, mais en reprenant.

    Le serveur doit vérifier ses enregistrements et s’il y a eu un upload de ce fichier et que la taille actuellement téléchargée est exactement X-Start-Byte, alors il y ajoute les données.

Voici la démo avec le code client et serveur, écrite sur Node.js.

Cela ne fonctionne que partiellement sur ce site, car Node.js est derrière un autre serveur nommé Nginx, qui met en mémoire tampon les uploads, en les transmettant à Node.js que lorsqu’il est complètement terminé.

Mais vous pouvez le télécharger et l’exécuter localement pour la démonstration complète :

Résultat
server.js
uploader.js
index.html
let http = require('http');
let static = require('node-static');
let fileServer = new static.Server('.');
let path = require('path');
let fs = require('fs');
let debug = require('debug')('example:resume-upload');

let uploads = Object.create(null);

function onUpload(req, res) {

  let fileId = req.headers['x-file-id'];
  let startByte = +req.headers['x-start-byte'];

  if (!fileId) {
    res.writeHead(400, "No file id");
    res.end();
  }

  // we'll files "nowhere"
  let filePath = '/dev/null';
  // could use a real path instead, e.g.
  // let filePath = path.join('/tmp', fileId);

  debug("onUpload fileId: ", fileId);

  // initialize a new upload
  if (!uploads[fileId]) uploads[fileId] = {};
  let upload = uploads[fileId];

  debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte)

  let fileStream;

  // if startByte is 0 or not set, create a new file, otherwise check the size and append to existing one
  if (!startByte) {
    upload.bytesReceived = 0;
    fileStream = fs.createWriteStream(filePath, {
      flags: 'w'
    });
    debug("New file created: " + filePath);
  } else {
    // we can check on-disk file size as well to be sure
    if (upload.bytesReceived != startByte) {
      res.writeHead(400, "Wrong start byte");
      res.end(upload.bytesReceived);
      return;
    }
    // append to existing file
    fileStream = fs.createWriteStream(filePath, {
      flags: 'a'
    });
    debug("File reopened: " + filePath);
  }


  req.on('data', function(data) {
    debug("bytes received", upload.bytesReceived);
    upload.bytesReceived += data.length;
  });

  // send request body to file
  req.pipe(fileStream);

  // when the request is finished, and all its data is written
  fileStream.on('close', function() {
    if (upload.bytesReceived == req.headers['x-file-size']) {
      debug("Upload finished");
      delete uploads[fileId];

      // can do something else with the uploaded file here

      res.end("Success " + upload.bytesReceived);
    } else {
      // connection lost, we leave the unfinished file around
      debug("File unfinished, stopped at " + upload.bytesReceived);
      res.end();
    }
  });

  // in case of I/O error - finish the request
  fileStream.on('error', function(err) {
    debug("fileStream error");
    res.writeHead(500, "File error");
    res.end();
  });

}

function onStatus(req, res) {
  let fileId = req.headers['x-file-id'];
  let upload = uploads[fileId];
  debug("onStatus fileId:", fileId, " upload:", upload);
  if (!upload) {
    res.end("0")
  } else {
    res.end(String(upload.bytesReceived));
  }
}


function accept(req, res) {
  if (req.url == '/status') {
    onStatus(req, res);
  } else if (req.url == '/upload' && req.method == 'POST') {
    onUpload(req, res);
  } else {
    fileServer.serve(req, res);
  }

}




// -----------------------------------

if (!module.parent) {
  http.createServer(accept).listen(8080);
  console.log('Server listening at port 8080');
} else {
  exports.accept = accept;
}
class Uploader {

  constructor({file, onProgress}) {
    this.file = file;
    this.onProgress = onProgress;

    // create fileId that uniquely identifies the file
    // we could also add user session identifier (if had one), to make it even more unique
    this.fileId = file.name + '-' + file.size + '-' + file.lastModified;
  }

  async getUploadedBytes() {
    let response = await fetch('status', {
      headers: {
        'X-File-Id': this.fileId
      }
    });

    if (response.status != 200) {
      throw new Error("Can't get uploaded bytes: " + response.statusText);
    }

    let text = await response.text();

    return +text;
  }

  async upload() {
    this.startByte = await this.getUploadedBytes();

    let xhr = this.xhr = new XMLHttpRequest();
    xhr.open("POST", "upload", true);

    // send file id, so that the server knows which file to resume
    xhr.setRequestHeader('X-File-Id', this.fileId);
    // send the byte we're resuming from, so the server knows we're resuming
    xhr.setRequestHeader('X-Start-Byte', this.startByte);

    xhr.upload.onprogress = (e) => {
      this.onProgress(this.startByte + e.loaded, this.startByte + e.total);
    };

    console.log("send the file, starting from", this.startByte);
    xhr.send(this.file.slice(this.startByte));

    // return
    //   true if upload was successful,
    //   false if aborted
    // throw in case of an error
    return await new Promise((resolve, reject) => {

      xhr.onload = xhr.onerror = () => {
        console.log("upload end status:" + xhr.status + " text:" + xhr.statusText);

        if (xhr.status == 200) {
          resolve(true);
        } else {
          reject(new Error("Upload failed: " + xhr.statusText));
        }
      };

      // onabort triggers only when xhr.abort() is called
      xhr.onabort = () => resolve(false);

    });

  }

  stop() {
    if (this.xhr) {
      this.xhr.abort();
    }
  }

}
<!DOCTYPE HTML>

<script src="uploader.js"></script>

<form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
  <input type="file" name="myfile">
  <input type="submit" name="submit" value="Upload (Resumes automatically)">
</form>

<button onclick="uploader.stop()">Stop upload</button>


<div id="log">Progress indication</div>

<script>
  function log(html) {
    document.getElementById('log').innerHTML = html;
    console.log(html);
  }

  function onProgress(loaded, total) {
    log("progress " + loaded + ' / ' + total);
  }

  let uploader;

  document.forms.upload.onsubmit = async function(e) {
    e.preventDefault();

    let file = this.elements.myfile.files[0];
    if (!file) return;

    uploader = new Uploader({file, onProgress});

    try {
      let uploaded = await uploader.upload();

      if (uploaded) {
        log('success');
      } else {
        log('stopped');
      }

    } catch(err) {
      console.error(err);
      log('error');
    }
  };

</script>

Comme nous pouvons le voir, les méthodes de mise en réseau modernes sont proches des gestionnaires de fichiers dans leurs capacités – contrôle des en-têtes, indicateur de progression, envoi de parties de fichier, etc…

Nous pouvons implémenter un upload pouvant être repris et bien plus encore.

Carte du tutoriel