Richieste di flussi di dati con l'API fetch

Jake Archibald
Jake Archibald

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

Puoi utilizzarlo per:

  • Fai riscaldare il server. In altre parole, potresti avviare la richiesta dopo che l'utente ha selezionato un campo di immissione di testo e aver rimosso tutte le intestazioni, quindi attendere che l'utente prema "Invia". prima di inviare i dati inseriti.
  • Invia in modo graduale i dati generati sul client, ad esempio dati audio, video o di input.
  • Ricrea i socket web su HTTP/2 o HTTP/3.

Ma poiché si tratta di una funzionalità di piattaforma web di basso livello, non farti limitare dalle mie idee. Pensa a un caso d'uso molto più entusiasmante per lo streaming di richieste.

Demo

Mostra come trasmettere i dati dall'utente al server e inviarli nuovamente, in modo da poterli elaborare in tempo reale.

Sì, ok, non è l'esempio più fantasioso, volevo solo essere semplice, ok?

Comunque, come funziona?

In precedenza, sulle emozionanti avventure dei live streaming di recupero

I flussi di risposta sono disponibili da un po' di tempo in tutti i browser moderni. Consentono di accedere a parti di una risposta 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 corrisponde a un Uint8Array di byte. Il numero di array che ottieni e la loro dimensione dipende dalla velocità della rete. Se utilizzi una connessione veloce, riceverai meno "blocchi" più grandi di dati. Se la tua connessione è lenta, riceverai più blocchi, più piccoli.

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 Uint8Array blocchi e li converte in stringhe.

I flussi sono un'ottima cosa, perché puoi iniziare a intervenire sui dati man mano che 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, si tratta di stream di risposta, l'entusiasmante nuova cosa di cui volevo parlare sono gli stream di richieste.

Corpo delle richieste di flussi di dati

Le richieste possono avere corpi:

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

In precedenza, per poter avviare la richiesta era necessario che tutto il corpo fosse pronto per iniziare, ma ora in Chromium 105 puoi fornire i tuoi 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',
});

Quanto sopra invierà il messaggio "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 un Uint8Array di byte, quindi utilizzo pipeThrough(new TextEncoderStream()) per eseguire la conversione al posto mio.

Restrizioni

Le richieste di streaming sono una nuova potenza per il web, perciò sono soggette ad alcune restrizioni:

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, se si tratta di un comportamento standard dipende da chi chiedi) è che puoi iniziare a ricevere la risposta mentre stai ancora inviando la richiesta. Tuttavia, è così poco noto 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, per alcune implementazioni, ad esempio fetch in Deno, è stata impostata la modalità "full duplex" per impostazione predefinita per i recuperi in modalità flusso, il che significa che la risposta può diventare disponibile prima del completamento della richiesta.

Quindi, per aggirare questo problema di compatibilità, nei browser, duplex: 'half' deve da specificare 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.

Nel frattempo, la cosa migliore successiva della comunicazione duplex è eseguire un recupero con una richiesta di flusso, quindi eseguire un altro recupero per ricevere la risposta in modalità flusso. Il server avrà bisogno di un modo per associare queste due richieste, come un ID nell'URL. Ecco come funziona la demo.

Reindirizzamenti limitati

Alcune forme di reindirizzamento HTTP richiedono al browser di inviare di nuovo il corpo della richiesta a un altro URL. A questo scopo, il browser dovrebbe eseguire il buffering dei contenuti dello stream, annullando di fatto il punto.

Se invece la richiesta ha un corpo di flusso 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 flussi di dati hanno un corpo, ma non un'intestazione Content-Length. Si tratta di un nuovo tipo di richiesta, per cui è richiesto CORS, che attiva sempre un preflight.

Le richieste di flusso di 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 di richiesta e risposta devono inviare un'intestazione Content-Length, in modo che l'altra parte sappia quanti dati riceverà o modificare il formato del messaggio per utilizzare la codifica a blocchi. Con la codifica a blocchi, il corpo viene suddiviso in parti, ciascuna con la propria lunghezza dei contenuti.

La codifica con chunking è abbastanza comune per le risposte HTTP/1.1, ma molto rara quando si tratta di richieste, quindi rappresenta un rischio di compatibilità eccessivo.

Problemi potenziali

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

Incompatibilità lato server

Alcuni server di app non supportano le richieste di streaming, ma attendono la ricezione della richiesta completa prima di poterne visualizzare una, cosa che non va a buon fine. Usa invece un server di app che supporti lo streaming, come NodeJS o Deno.

Ma non sei ancora fuori dai boschi! Il server delle applicazioni, come NodeJS, di solito si trova dietro un altro server, spesso chiamato "server front-end", che a sua volta potrebbe trovarsi dietro una rete CDN. Se uno di questi decide di eseguire il buffering della richiesta prima di trasmetterla al server successivo nella catena, perderai il vantaggio del flusso di richieste.

Incompatibilità al di fuori del tuo controllo

Poiché questa funzione funziona solo su HTTPS, non devi preoccuparti dei proxy tra te e l'utente, ma quest'ultimo potrebbe eseguire un proxy sul suo computer. Alcuni software di protezione internet lo fanno per consentire di monitorare tutto ciò che passa tra il browser e la rete e, in alcuni casi, questo software potrebbe memorizzare i corpi delle richieste.

Se vuoi proteggerti, puoi creare un "test delle caratteristiche" in modo simile alla demo mostrata sopra, in cui provi a trasmettere alcuni dati senza chiuderlo. Se il server riceve i dati, può rispondere tramite un recupero diverso. In questo caso, sai che il client supporta le richieste di flussi di dati end-to-end.

Rilevamento delle caratteristiche

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 vuoi saperne di più, ecco come funziona il rilevamento delle caratteristiche:

Se il browser non supporta un determinato tipo di body, chiama toString() sull'oggetto e utilizza il risultato come corpo. Quindi, 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. Quindi, se l'intestazione è impostata, sappiamo che il browser non supporta i flussi negli oggetti della richiesta e possiamo uscire in anticipo.

Safari supporta gli stream negli oggetti di richiesta, ma non ne consente l'utilizzo con fetch, pertanto viene testata l'opzione duplex, che attualmente non è supportata da Safari.

Utilizzo con flussi scrivibili

A volte è più facile lavorare con gli stream se hai un WritableStream. Puoi farlo utilizzando un modello di identità , una coppia leggibile/scrivibile che prende tutto ciò che è passato alla sua fine in scrittura e lo invia alla fine leggibile. Puoi creare uno di questi elementi creando un TransformStream senza argomenti:

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

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

Ora, tutto ciò che invii al flusso scrivibile sarà incluso nella richiesta. In questo modo è possibile comporre i live streaming insieme. Ecco un esempio sciocco 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 i flussi di compressione per comprimere dati arbitrari utilizzando gzip.