Richieste di flussi di dati con l'API fetch

Jake Archibald
Jake Archibald

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

Puoi utilizzarlo per:

  • Riscalda il server. In altre parole, potresti avviare la richiesta dopo che l'utente ha impostato un campo di immissione di testo e ha eliminato tutte le intestazioni, quindi dovrai 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 basso livello della piattaforma web, non essere limitata dalle mie idee. Forse vi viene in mente un caso d'uso molto più entusiasmante per lo streaming di richieste.

Demo

Qui viene mostrato come trasmettere i dati dall'utente al server e come inviare i dati che possono essere elaborati in tempo reale.

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

Comunque, come funziona?

In precedenza, le entusiasmanti avventure degli stream di recupero

Gli stream di risposta sono disponibili in tutti i browser moderni da un po' di tempo. Consentono di accedere a parti di una risposta non appena 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 matrici ottenute e le loro dimensioni dipendono dalla velocità della rete. Se utilizzi una connessione veloce, riceverai meno "blocchi" di dati più grandi. Se la tua connessione è lenta, otterrai blocchi più numerosi, 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 è uno stream di trasformazione che acquisisce tutti questi Uint8Array blocchi e li converte in stringhe.

I flussi sono fantastici, 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, invece di attendere tutti i 100.

Comunque, si tratta di stream di risposta, la novità entusiasmante di cui volevo parlare sono gli stream di richiesta.

Corpi delle richieste di streaming

Le richieste possono avere un corpo:

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

In precedenza, prima di poter avviare la richiesta era necessario che tutto il corpo fosse pronto, 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',
});

Il comando riportato 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, pertanto sono soggette ad alcune limitazioni:

Half-duplex?

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

Una caratteristica poco nota di HTTP (sebbene, il fatto che si tratti di un comportamento standard dipende dalla persona che chiedi) è che puoi iniziare a ricevere la risposta mentre stai ancora inviando la richiesta. Tuttavia, è così poco conosciuto da non essere supportato dai server e non è supportato da nessun browser.

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

Questo pattern predefinito è noto come "half duplex". Tuttavia, per alcune implementazioni, come fetch in Deno, il valore predefinito è "full duplex" per i recuperi di flussi di dati, il che significa che la risposta può diventare disponibile prima del completamento della richiesta.

Quindi, per risolvere questo problema di compatibilità, nei browser è necessario specificare duplex: 'half' nelle richieste che hanno un corpo dello stream.

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

Nel frattempo, la cosa migliore per la comunicazione duplex è effettuare un recupero con una richiesta di streaming, quindi fare un altro recupero per ricevere la risposta di streaming. 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 che il browser invii nuovamente il corpo della richiesta a un altro URL. Per supportare ciò, il browser dovrebbe eseguire il buffering dei contenuti del flusso, il che in qualche modo va a sfatare il punto, quindi non esegue questa operazione.

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, poiché modificano esplicitamente il metodo in GET ed eliminano il corpo della richiesta.

Richiede CORS e attiva un preflight

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

Non sono consentite richieste di streaming no-cors.

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à oppure cambierà il formato del messaggio per utilizzare la codifica in blocchi. Con la codifica a blocchi, il corpo viene suddiviso in parti, ciascuna con la propria lunghezza dei contenuti.

La codifica chunked è piuttosto comune per le risposte HTTP/1.1, ma è molto rara per le richieste, pertanto rappresenta un rischio di compatibilità eccessivo.

Potenziali problemi

Si tratta di una nuova funzionalità, attualmente sottoutilizzata su Internet. Ecco alcuni problemi da tenere presenti:

Incompatibilità lato server

Alcuni server delle app non supportano le richieste di streaming e, al contrario, attendono la ricezione della richiesta completa prima di potervi visualizzare. Utilizza invece un server di app che supporti lo streaming, come NodeJS o Deno.

Ma non sei ancora fuori dal tavolo! Il server delle applicazioni, come NodeJS, di solito si trova dietro un altro server, spesso chiamato "server frontend", che a sua volta potrebbe trovarsi dietro una CDN. Se qualcuno di questi decide di eseguire il buffering della richiesta prima di passarla al server successivo della catena, perderai il vantaggio del flusso di richieste.

Incompatibilità al di fuori del tuo controllo

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

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

Rilevamento 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 vuoi saperne di più, 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. Quindi, se il browser non supporta gli stream 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 gli stream negli oggetti richiesta e possiamo uscire in anticipo.

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

Utilizzo con stream scrivibili

A volte è più facile lavorare con gli stream se hai un WritableStream. Puoi farlo utilizzando un flusso "identity", una coppia leggibile/scrivibile che prende tutto ciò che viene passato al suo lato scrivibile e lo invia all'estremità 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 allo stream accessibile in scrittura sarà incluso nella richiesta. Questo ti consente di scrivere stream 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 sopra utilizza flussi di compressione per comprimere i dati arbitrari utilizzando gzip.