Richieste di flussi di dati con l'API fetch

Jake Archibald
Jake Archibald

A partire da Chromium 105, puoi avviare una richiesta prima che sia disponibile l'intero corpo utilizzando l'API Streams.

Potresti utilizzarlo per:

  • Esegui il riscaldamento del server. In altre parole, puoi avviare la richiesta quando l'utente mette in primo piano un campo di immissione di testo e rimuovere tutte le intestazioni, quindi 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 socket web su HTTP/2 o HTTP/3.

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

Demo

Questo mostra come puoi trasmettere in streaming i dati dall'utente al server e inviare dati che possono essere elaborati in tempo reale.

Sì, non è l'esempio più fantasioso, volevo solo mantenere la semplicità, ok?

Comunque, come funziona?

Le precedenti avventure degli stream di recupero

Gli stream di risposta sono disponibili in tutti i browser moderni da un po' di tempo. Ti consentono di accedere alle 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 "chunk" di dati più grandi. Se la connessione è lenta, riceverai più blocchi più piccoli.

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

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

TextDecoderStream è uno stream di trasformazione che acquisisce tutti i chunk Uint8Array e li converte in stringhe.

Gli stream sono fantastici perché puoi iniziare a intervenire sui 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 e 100.

Ad ogni modo, questi sono gli stream di risposta. La novità interessante di cui volevo parlare sono gli stream di richiesta.

Corpi delle richieste di streaming

Le richieste possono avere i seguenti tipi di corpo:

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

In precedenza, era necessario che tutto il corpo fosse pronto 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 codice riportato sopra invierà al server la stringa "Questa è una richiesta lenta", una parola alla volta, con una pausa di un secondo tra ogni parola.

Ogni chunk del corpo della richiesta deve essere un Uint8Array di byte, quindi utilizzo pipeThrough(new TextEncoderStream()) per eseguire la conversione per me.

Restrizioni

Le richieste di streaming sono una nuova risorsa per il web, quindi presentano alcune limitazioni:

Half duplex?

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

Una funzionalità poco nota di HTTP (anche se il fatto che si tratti di un comportamento standard dipende da chi lo chiedi) è che puoi iniziare a ricevere la risposta mentre stai ancora inviando la richiesta. Tuttavia, è così poco conosciuto che non è supportato bene dai server e non è supportato da nessun 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 modello predefinito è noto come "half duplex". Tuttavia, alcune implementazioni, come fetch in Deno, utilizzano per impostazione predefinita il valore "full duplex" per i recuperi in streaming, il che significa che la risposta può diventare disponibile prima del completamento della richiesta.

Pertanto, per risolvere questo problema di compatibilità, in browser duplex: 'half' deve essere specificato per le richieste che hanno un corpo dello stream.

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 è eseguire un recupero con una richiesta di streaming, quindi un altro recupero per ricevere la risposta in 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 con limitazioni

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 mettere in buffer i contenuti dello stream, il che vanifica un po' lo scopo, quindi non lo fa.

Se invece la richiesta ha un corpo in 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, poiché 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 no-cors in streaming 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 campi della richiesta e della risposta 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 a blocchi è abbastanza comune per le risposte HTTP/1.1, ma molto rara per le richieste, quindi rappresenta un rischio di compatibilità troppo elevato.

Potenziali problemi

Si tratta di una nuova funzionalità, sottoutilizzata al momento su internet. Di seguito sono riportati alcuni problemi da tenere in considerazione:

Incompatibilità lato server

Alcuni server app non supportano le richieste in streaming e aspettano di ricevere la richiesta completa prima di mostrarti qualcosa, il che rende inutile l'operazione. Utilizza invece un server app che supporta lo streaming, come NodeJS o Deno.

Ma non è ancora finita. Il server di applicazioni, come NodeJS, in genere si trova dietro un altro server, spesso chiamato "server front-end", che a sua volta può trovarsi dietro una CDN. Se uno di questi decide di mettere in buffer la richiesta prima di passarla al server successivo della catena, perdi il vantaggio dello streaming delle richieste.

Incompatibilità non sotto il 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 sulla propria macchina. Alcuni software di protezione di internet eseguono questa operazione per monitorare tutto ciò che passa tra il browser e la rete e, in alcuni casi, questo software memorizza nella cache i corpi delle richieste.

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

Rilevamento di 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 {
  // …
}

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 gli stream di richieste, il corpo della richiesta diventa la stringa "[object ReadableStream]". Quando viene utilizzata una stringa come corpo, l'intestazione Content-Type viene impostata comodamente su text/plain;charset=UTF-8. Pertanto, se l'intestazione è impostata, sappiamo che il browser non supporta gli stream negli oggetti di richiesta e possiamo uscire in anticipo.

Safari supporta gli stream negli oggetti request, 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 leggibile/scrivibile che prende tutto ciò che viene passato al suo lato di scrittura e lo invia al lato di lettura. Puoi creare uno di questi oggetti 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 modificabile farà parte della richiesta. In questo modo puoi comporre gli stream 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 riportato sopra utilizza gli stream di compressione per comprimere dati arbitrari utilizzando gzip.