31 marzo 2021

Upload del file ripristinabile

Con il metodo fetch è abbastanza semplice eseguire l’upload di un file.

Come possiamo ripristinare l’upload di un file dopo avere perso la connessione? Non esistono opzioni built-in per questa operazione, ma abbiamo dei pezzi di codice per implementarlo.

Il ripristino degli upload dovrebbe andare a braccetto con la possibilità di tenerne traccia durante il trasferimento, come ci aspetteremmo per files di grosse dimensioni (se abbiamo bisogno di ripristinare l’operazione). Dal momento che fetch non permette di tenere traccia dell’upload, allora dobbiamo rifarci all’uso di XMLHttpRequest.

Evento di progresso non-così-utile

Per ripristinare un upload, dobbiamo conoscere la quantità di dati trasferiti prima che la connessione si interrompesse.

Per tenere traccia del progresso di upload possiamo usare xhr.upload.onprogress.

Sfortunatamente, questo non ci aiuta nel ripristinare l’upload, dal momento che questo evento viene scatenato solamente quando il dato è stato inviato. Ma è stato ricevuto dal server? Il browser non lo sa.

Magari potrebbe essere stato bufferizzato da qualche proxy di rete locale, o magari il processo del server remoto è stato terminato e non è più in grado di processarlo, oppure è stato perso nel bel mezzo del trasferimento e non raggiunge il ricevente.

Questo è il motivo per il quale la sua utilità si limita a mostrare una carinissima barra di caricamento.

Per ripristinare l’upload, abbiamo bisogno di conoscere esattamente il numero di bytes ricevuti dal server. E questa informazione può darcela solamente il server, motivo per il quale andiamo a creare una richiesta aggiuntiva.

Algoritmo

  1. Per prima cosa, creiamo un id del file, per identificare univocamente ciò che stiamo andando a trasferire:

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

    Ciò è necessario per ripristinare l’upload, per dire al server cosa stiamo ripristinando.

    Se il nome, la dimensione, oppure la data di ultima modifica sono differenti, allora ci sarà un fileId differente.

  2. Inviamo una richiesta al server, chiedendo quanti bytes possiede già di quel file:

    let response = await fetch('status', {
      headers: {
        'X-File-Id': fileId
      }
    });
    
    // Il server possiede questo numero di bytes
    let startByte = +await response.text();

    Questo presume che il server tenga traccia degli upload dei files tramite l’header X-File-Id. Dovrebbe essere implementato lato server.

    Se il file non esiste ancora nel server, il valore della risposta dovrebbe essere 0

  3. Quindi, possiamo usare il metodo slice di Blob per inviare il file partendo da startByte:

    xhr.open("POST", "upload", true);
    
    // File id, in modo tale che il server possa sapere di quale file stiamo eseguendo l'upload
    xhr.setRequestHeader('X-File-Id', fileId);
    
    // Il byte a partire dal quale stiamo eseguendo il ripristino, in modo da consentire al server di sapere da che punto stiamo cominciando a ripristinare
    xhr.setRequestHeader('X-Start-Byte', startByte);
    
    xhr.upload.onprogress = (e) => {
      console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    };
    
    // il file puo' provenire da input.files[0] o da altra fonte
    xhr.send(file.slice(startByte));

    Qui inviamo al server sia il file id come X-File-Id, di modo che sappia quale file stiamo trasferendo, e da quale byte stiamo ripartendo tramite X-Start-Byte, cosicché sappia che non stiamo partendo dall’inizio, ma che, invece, stiamo ripristinando.

    Il server dovrebbe controllare i suoi registri, e nel caso in cui trovasse un upload del file, e la dimensione attualmente caricata fosse esattamente di X-Start-Byte, accoderebbe i dati al file.

Ecco una demo con il codice client e la relativa parte server, scritta in Node.js.

Funziona parzialmente su questo sito, dal momento che Node.js sta su un altro server chiamato Nginx, che bufferizza gli uploads, passandoglieli solo a trasferimento completato.

È comunque possibile scaricare l’esempio ed eseguirlo in locale per la dimostrazione completa:

Risultato
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();
  }

  // non salveremo "da nessuna parte"
  let filePath = '/dev/null';
  // invece potremmo usare un percorso reale, ad esempio
  // let filePath = path.join('/tmp', fileId);

  debug("onUpload fileId: ", fileId);

  // inizializza un nuovo upload
  if (!uploads[fileId]) uploads[fileId] = {};
  let upload = uploads[fileId];

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

  let fileStream;

  // se startByte e' 0 o non e' impostato, crea un nuovo file, altrimenti controlla la dimensione del file e lo accoda a quello esistente
  if (!startByte) {
    upload.bytesReceived = 0;
    fileStream = fs.createWriteStream(filePath, {
      flags: 'w'
    });
    debug("New file created: " + filePath);
  } else {
    // possiamo controllare su disco la dimensione del file per sicurezza
    if (upload.bytesReceived != startByte) {
      res.writeHead(400, "Wrong start byte");
      res.end(upload.bytesReceived);
      return;
    }
    // accoda al file esistente
    fileStream = fs.createWriteStream(filePath, {
      flags: 'a'
    });
    debug("File reopened: " + filePath);
  }


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

  // invia il corpo della richiesta al file
  req.pipe(fileStream);

  // quando la richiesta è stata completata, e tutti i dati sono stati scritti
  fileStream.on('close', function() {
    if (upload.bytesReceived == req.headers['x-file-size']) {
      debug("Upload finished");
      delete uploads[fileId];

      // qui puo' fare qualcos'altro con il file caricato

      res.end("Success " + upload.bytesReceived);
    } else {
      // connessione persa, lasciamo il file incompleto
      debug("File unfinished, stopped at " + upload.bytesReceived);
      res.end();
    }
  });

  // in caso di errore I/O - conclude la richiesta
  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;

    // crea un fileId che identifica univocamente il file
    // potremmo usare l'identificatore di sessione dell'utente (avendone uno) per essere ancora piu' sicuri della sua univocita'
    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);

    // invia il file id, in modo da consentire al server di conoscere quale file ripristinare
    xhr.setRequestHeader('X-File-Id', this.fileId);
    // invia la posizione del byte dal quale stiamo partendo per il ripristino, in modo da informare il server da dove stiamo ripartendo
    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 se l'upload e' andato a buon fine
    //   false se annullato
    // throw in caso di errore
    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 viene scatenato solo se viene chiamato xhr.abort()
      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 (Ripristina automaticamente)">
</form>

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


<div id="log">Indicatore del progresso di upload</div>

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

  function onProgress(loaded, total) {
    log("progresso " + 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('completato con successo');
      } else {
        log('interrotto');
      }

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

</script>

Come possiamo vedere, i moderni metodi di rete sono molto vicini all’essere dei gestori di files nelle loro capacità, controllo degli headers, indicazione del progresso di upload, invio di frammenti di files etc.

Possiamo implementare, quindi, upload ripristinabili e molto altro ancora.

Mappa del tutorial