Richieste di flussi di dati con l'API fetch

Jake Archibald
Jake Archibald

A partire da Chromium 105, puoi avviare una richiesta prima di avere a disposizione l'intero corpo utilizzando l'API Streams.

Puoi utilizzarlo per:

  • Riscalda il server. In altre parole, potresti avviare la richiesta una volta che l'utente si concentra su un campo di immissione del testo, eliminare tutte le intestazioni e attendere che l'utente prema "Invia" prima di inviare i dati inseriti.
  • Invia gradualmente i dati generati sul client, ad esempio audio, video o dati di input.
  • Ricrea i websocket su HTTP/2 o HTTP/3.

Tuttavia, poiché si tratta di una funzionalità della piattaforma web di basso livello, non limitarti alle mie idee. Forse ti viene in mente un caso d'uso molto più interessante per lo streaming delle richieste.

Riepilogo delle entusiasmanti avventure degli stream di recupero

I flussi Risposta sono disponibili da tempo in tutti i browser moderni. Consentono di accedere a parti di una risposta man mano che arrivano dal server:

const response = await fetch(url);
const reader = response.body.getReader();

while (true) {
  const {value, done} = await reader.read();
  if (done) break;
  console.log('Received', value);
}

console.log('Response fully received');

Ogni value è un Uint8Array di byte. Il numero e le dimensioni degli array dipendono dalla velocità della rete. Se hai una connessione veloce, riceverai meno "blocchi" di dati, ma più grandi. Se la connessione è lenta, riceverai più blocchi di dimensioni più piccole.

Se vuoi convertire i byte in testo, puoi utilizzare TextDecoder o il flusso di trasformazione più recente se i browser di destinazione lo supportano:

const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

TextDecoderStream è un flusso di trasformazione che acquisisce tutti i blocchi Uint8Array e li converte in stringhe.

Gli stream sono ottimi perché puoi iniziare a utilizzare i dati non appena arrivano. Ad esempio, se ricevi un elenco di 100 "risultati", puoi visualizzare il primo risultato non appena lo ricevi, anziché attendere tutti i 100.

Comunque, questi sono gli stream di risposte. La novità entusiasmante di cui volevo parlare sono gli stream di richieste.

Corpi delle richieste di streaming

Le richieste possono avere corpi:

await fetch(url, {
  method: 'POST',
  body: requestBody,
});

In precedenza, dovevi preparare l'intero corpo prima di poter avviare la richiesta, ma ora in Chromium 105 puoi fornire il tuo ReadableStream di dati:

function wait(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

const stream = new ReadableStream({
  async start(controller) {
    await wait(1000);
    controller.enqueue('This ');
    await wait(1000);
    controller.enqueue('is ');
    await wait(1000);
    controller.enqueue('a ');
    await wait(1000);
    controller.enqueue('slow ');
    await wait(1000);
    controller.enqueue('request.');
    controller.close();
  },
}).pipeThrough(new TextEncoderStream());

fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'text/plain'},
  body: stream,
  duplex: 'half',
});

Il comando precedente invierà "This is a slow request" (Questa è una richiesta lenta) al server, una parola alla volta, con una pausa di un secondo tra una parola e l'altra.

Ogni blocco del corpo di una richiesta deve essere di Uint8Array byte, quindi utilizzo pipeThrough(new TextEncoderStream()) per eseguire la conversione.

Restrizioni

Le richieste di streaming sono una nuova funzionalità per il web, quindi sono soggette ad alcune limitazioni:

Half duplex?

Per consentire l'utilizzo dei flussi in una richiesta, l'opzione di richiesta duplex deve essere impostata su 'half'.

Una funzionalità poco nota di HTTP (anche se il comportamento standard dipende da chi lo chiede) è che puoi iniziare a ricevere la risposta mentre stai ancora inviando la richiesta. Tuttavia, è così poco conosciuto che non è ben supportato dai server e non è supportato da alcun browser.

Nei browser, la risposta non diventa mai disponibile finché il corpo della richiesta non è stato inviato completamente, anche se il server invia una risposta prima. Questo vale per tutti i recuperi del browser.

Questo pattern predefinito è noto come "half duplex". Tuttavia, alcune implementazioni, come fetch in Deno, utilizzavano per impostazione predefinita la modalità "full duplex" per i recuperi di streaming, il che significa che la risposta può diventare disponibile prima che la richiesta sia completata.

Pertanto, per risolvere questo problema di compatibilità, nei browser, duplex: 'half' deve essere specificato nelle richieste che hanno un corpo del flusso.

In futuro, duplex: 'full' potrebbe essere supportato nei browser per le richieste di streaming e non streaming.

Nel frattempo, la soluzione migliore per la comunicazione duplex è effettuare un recupero con una richiesta di streaming, quindi effettuare un altro recupero per ricevere la risposta di streaming. Il server avrà bisogno di un modo per associare queste due richieste, ad esempio un ID nell'URL. Ecco come funziona la demo.

Reindirizzamenti limitati

Alcune forme di reindirizzamento HTTP richiedono al browser di inviare nuovamente il corpo della richiesta a un altro URL. Per supportare questa funzionalità, il browser dovrebbe memorizzare nel buffer i contenuti dello stream, il che vanificherebbe in qualche modo lo scopo, quindi non lo fa.

Se invece la richiesta ha un corpo di streaming e la risposta è un reindirizzamento HTTP diverso da 303, il recupero verrà rifiutato e il reindirizzamento non verrà seguito.

I reindirizzamenti 303 sono consentiti, in quanto modificano esplicitamente il metodo in GET e ignorano il corpo della richiesta.

Richiede CORS e attiva un preflight

Le richieste di streaming hanno un corpo, ma non hanno un'intestazione Content-Length. Si tratta di un nuovo tipo di richiesta, quindi è necessario CORS e queste richieste attivano sempre un preflight.

Le richieste di streaming no-cors non sono consentite.

Non funziona su HTTP/1.x

Il recupero verrà rifiutato se la connessione è HTTP/1.x.

Questo perché, secondo le regole HTTP/1.1, i corpi delle richieste e delle risposte devono inviare un'intestazione Content-Length, in modo che l'altra parte sappia quanti dati riceverà, oppure modificare il formato del messaggio per utilizzare la codifica a blocchi. Con la codifica a blocchi, il corpo viene suddiviso in parti, ognuna con la propria lunghezza dei contenuti.

La codifica chunked è piuttosto comune per le risposte HTTP/1.1, ma molto rara per le richieste, quindi il rischio di incompatibilità è troppo elevato.

Potenziali problemi

Si tratta di una nuova funzionalità, poco utilizzata su internet al giorno d'oggi. Ecco alcuni problemi da tenere in considerazione:

Incompatibilità lato server

Alcuni server delle app non supportano le richieste di streaming e attendono invece la ricezione della richiesta completa prima di consentirti di visualizzarne una parte, il che vanifica lo scopo. Utilizza invece un server delle app che supporti lo streaming, come NodeJS o Deno.

Ma non sei ancora fuori dai guai. Il server delle applicazioni, ad esempio NodeJS, si trova in genere dietro un altro server, spesso chiamato "server front-end", che a sua volta può trovarsi dietro una CDN. Se uno di questi decide di memorizzare nel buffer la richiesta prima di inviarla al server successivo della catena, perdi il vantaggio dello streaming delle richieste.

Incompatibilità al di fuori del tuo controllo

Poiché questa funzionalità funziona solo tramite HTTPS, non devi preoccuparti dei proxy tra te e l'utente, ma l'utente potrebbe eseguire un proxy sul proprio computer. Alcuni software di protezione di internet lo fanno per monitorare tutto ciò che passa tra il browser e la rete e potrebbero esserci casi in cui questo software memorizza nel buffer i corpi delle richieste.

Se vuoi proteggerti da questo problema, puoi creare un "test delle funzionalità" simile alla demo riportata sopra, in cui provi a trasmettere in streaming alcuni dati senza chiudere lo stream. Se il server riceve i dati, può rispondere tramite un recupero diverso. Quando ciò accade, sai che il client supporta le richieste di streaming end-to-end.

Rilevamento delle funzionalità

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

if (supportsRequestStreams) {
  // …
} else {
  // …
}

Se ti incuriosisce, ecco come funziona il rilevamento delle funzionalità:

Se il browser non supporta un determinato tipo di body, chiama toString() sull'oggetto e utilizza il risultato come corpo. Pertanto, se il browser non supporta i flussi di richieste, il corpo della richiesta diventa la stringa "[object ReadableStream]". Quando una stringa viene utilizzata come corpo, imposta comodamente l'intestazione Content-Type su text/plain;charset=UTF-8. Pertanto, se questa intestazione è impostata, sappiamo che il browser non supporta i flussi negli oggetti richiesta e possiamo uscire in anticipo.

Safari supporta i flussi negli oggetti richiesta, ma non consente di utilizzarli con fetch, pertanto viene testata l'opzione duplex, che Safari non supporta attualmente.

Utilizzo con stream scrivibili

A volte è più facile lavorare con gli stream quando hai un WritableStream. Puoi farlo utilizzando uno stream "identity", ovvero una coppia di lettura/scrittura che prende tutto ciò che viene passato alla sua estremità scrivibile e lo invia all'estremità leggibile. Puoi crearne uno creando un TransformStream senza argomenti:

const {readable, writable} = new TransformStream();

const responsePromise = fetch(url, {
  method: 'POST',
  body: readable,
});

Ora, tutto ciò che invii allo stream scrivibile farà parte della richiesta. In questo modo puoi comporre i flussi insieme. Ad esempio, ecco un esempio banale in cui i dati vengono recuperati da un URL, compressi e inviati a un altro URL:

// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();

// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);

// Post to url2:
await fetch(url2, {
  method: 'POST',
  body: readable,
});

L'esempio precedente utilizza flussi di compressione per comprimere dati arbitrari utilizzando gzip.