Next.js 15 -fullstack-kehitys (App Router + Server Actions) · Oppitunti

Vastausten suoratoisto ja ReadableStream-käsittelijöissä

Palauttakaa dataa vähitellen ReadableStreamilla esimerkiksi tekoälytokeneita, lokeja ja vaiheittain muodostuvia hyötykuormia varten.

Oppitunti 3/413 vaihetta

Vastausten suoratoisto ja ReadableStream-käsittelijöissä on ilmainen Next.js 15 -fullstack-kehitys (App Router + Server Actions)-oppitunti CoddyKitissä. Tämä on oppitunti 3/4. Voit lukea tästä oppimispolusta kokonaan mitkä tahansa 3 oppituntia ilmaiseksi — sen jälkeen CoddyKit PRO avaa kaikki oppitunnit sekä käytännön harjoittelun sisäänrakennetulla koodieditorilla ja ympäri vuorokauden toimivalla tekoälytuutorilla. Oppitunti kuuluu Next.js 15 -fullstack-kehitys (App Router + Server Actions)-oppimispolkuun, ja edistymisesi synkronoituu verkon ja CoddyKit-sovelluksen välillä. Next.js 15 -fullstack-kehitys (App Router + Server Actions)-kurssilla on yhteensä 4 oppituntia.

Miksi vastaus kannattaa suoratoistaa?

Tavallinen Route Handler muodostaa koko rungon muistiin ja lähettää sen sitten kerralla. Tekoälyn token-tulosteen, reaaliaikaisten lokien tai suurten raporttien tapauksessa käyttäjä näkee tyhjän näytön aivan loppuun asti.

Suoratoisto muuttaa tilanteen: lähetätte osia asiakkaalle heti niiden valmistuttua. Selain alkaa näyttää ensimmäisiä tavuja välittömästi, ensimmäisen tavun saapumiseen kuluva aika lyhenee, eikä koko hyötykuormaa tarvitse koskaan pitää RAM-muistissa.

  • ReadableStream on Web Standard -perusprimitiivi, jota Next.js 15 käyttää tähän.
  • Palautatte sen suoraan Route Handlerista Response-olion sisällä.
  • Se toimii sekä Node.js- että Edge-ajoympäristöissä.

ReadableStream-rakenne

ReadableStream luodaan oliolla, joka sisältää start(controller)-metodin. Sen sisällä kutsutte controller.enqueue(chunk)-metodia datan lähettämiseen ja controller.close()-metodia, kun kaikki on valmis.

Osan tulisi olla tavuista koostuva Uint8Array. TextEncoder muuntaa merkkijonon näiksi tavuiksi. Kyseessä on puhdas Web API -koodi, joten se toimii kaikkialla, missä on nykyaikainen JS-moottori.

const encoder = new TextEncoder();

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue(encoder.encode("Hello, "));
    controller.enqueue(encoder.encode("streamed "));
    controller.enqueue(encoder.encode("world!"));
    controller.close();
  },
});

const response = new Response(stream);
console.log("Stream and Response created:", response instanceof Response);

Virran palauttaminen käsittelijästä

Tiedostossa app/api/.../route.ts viette HTTP-metodifunktion (tässä GET) ja palautatte Response-olion, jonka runkona virta toimii.

Asettakaa aina Content-Type. Tavalliselle asteittain lähetettävälle tekstille text/plain riittää; rakenteisille tapahtumavirroille käytätte text/event-stream-tyyppiä (käsitellään myöhemmin).

Tämä on kehyskoodia, joka tarvitsee Next.js-palvelimen, joten sitä ei voi suorittaa itsenäisesti.

// app/api/hello/route.ts
export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(encoder.encode("chunk-1\n"));
      controller.enqueue(encoder.encode("chunk-2\n"));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Suoratoisto ajan kuluessa async start -menetelmällä

Suoratoiston todellinen voima näkyy, kun osat saapuvat ajan kuluessa. start-metodi voi olla async, jolloin voitte käyttää await-lauseketta enqueue-kutsujen välillä.

Tässä pieni viive simuloi työtä (tekoälypalveluntarjoajaa, hidasta kyselyä tai työvaihetta). Kukin rivi saapuu asiakkaalle heti, kun se lisätään jonoon, eikä vasta silmukan päätyttyä.

  • Käyttäkää await-lauseketta tulosteen rytmittämiseen estämättä tapahtumasilmukkaa.
  • Älkää koskaan unohtako controller.close()-kutsua, muuten yhteys jää avoimeksi.
const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const stream = new ReadableStream({
  async start(controller) {
    for (let i = 1; i <= 3; i++) {
      await sleep(50);
      controller.enqueue(encoder.encode(`step ${i}\n`));
    }
    controller.close();
  },
});

const reader = stream.getReader();
const decoder = new TextDecoder();
let out = "";
let result = await reader.read();
while (!result.done) {
  out += decoder.decode(result.value);
  result = await reader.read();
}
console.log(out.trim());

Tekoälyn tokenien suoratoisto

Klassinen käyttötapaus on LLM:n tokenivirran välittäminen suoraan selaimeen, jolloin teksti ilmestyy sana sanalta. Useimmat AI SDK:t tarjoavat osittaisista osista koostuvan asynkronisen iteroitavan.

Käytte iteroitavan läpi start-metodin sisällä ja lisäätte jonoon kunkin tokenin tekstimuutoksen. Käyttäjä näkee vastauksen muodostuvan reaaliajassa aivan kuten chat-käyttöliittymässä.

// app/api/chat/route.ts
import { openai } from "@/lib/openai";

export async function POST(req: Request): Promise<Response> {
  const { prompt } = await req.json();
  const encoder = new TextEncoder();

  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    stream: true,
    messages: [{ role: "user", content: prompt }],
  });

  const stream = new ReadableStream({
    async start(controller) {
      for await (const part of completion) {
        const token = part.choices[0]?.delta?.content ?? "";
        if (token) controller.enqueue(encoder.encode(token));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Server-Sent Events (SSE) -muoto

Kun tarvitsette nimettyjä ja rakenteisia tapahtumia, jotka selaimen EventSource ymmärtää, käyttäkää SSE-siirtomuotoa ja text/event-stream-sisältötyyppiä.

Jokainen viesti on rivi, joka alkaa tekstillä data: ja jota seuraa hyötykuorma. Viestin päättää kaksi rivinvaihtoa (\n\n). Voitte sarjallistaa JSONin tekstin data: jälkeen.

  • Content-Type: text/event-stream
  • Cache-Control: no-cache, jotta välityspalvelimet eivät puskuroi sisältöä.
  • Connection: keep-alive Node-ajoympäristössä.
// app/api/events/route.ts
export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (const status of ["queued", "running", "done"]) {
        const payload = JSON.stringify({ status });
        controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
        await new Promise((r) => setTimeout(r, 300));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Edistymislokien suoratoisto

Pitkäkestoiset työt (tuonnit, koontiversiot ja eräkäsittely) hyötyvät edistymislokin suoratoistosta. Jokainen valmis vaihe lisätään jonoon, jolloin asiakas voi päivittää reaaliaikaista konsolia ilman kyselyitä.

Toimintamalli on sama: suorittakaa työ, lisätkää rivi jonoon ja toistakaa. Alla on suoritettava simulaatio monivaiheisesta työstä, joka tuottaa NDJSON-muotoa (yhden JSON-olion riville).

const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const steps = ["fetch", "transform", "upload"];

const stream = new ReadableStream({
  async start(controller) {
    for (let i = 0; i < steps.length; i++) {
      await sleep(30);
      const line = JSON.stringify({ step: steps[i], pct: ((i + 1) / steps.length) * 100 });
      controller.enqueue(encoder.encode(line + "\n"));
    }
    controller.close();
  },
});

const reader = stream.getReader();
const decoder = new TextDecoder();
let buf = "";
let r = await reader.read();
while (!r.done) {
  buf += decoder.decode(r.value);
  r = await reader.read();
}
for (const l of buf.trim().split("\n")) console.log(JSON.parse(l).step);

Asiakkaan yhteyden katkeamisen käsittely

Jos käyttäjä sulkee välilehden suoratoiston aikana, työnteko kannattaa lopettaa. Request-olio sisältää req.signal-kohdassa AbortSignal-olion, joka aktivoituu yhteyden katketessa.

Tarkistakaa silmukan sisällä req.signal.aborted ja käyttäkää halutessanne virran cancel()-takaisinkutsua resurssien vapauttamiseen (sulkekaa esimerkiksi LLM-yhteys tai keskeyttäkää tietokantakursori).

// app/api/long/route.ts
export async function GET(req: Request): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 100; i++) {
        if (req.signal.aborted) break; // client left
        controller.enqueue(encoder.encode(`tick ${i}\n`));
        await new Promise((r) => setTimeout(r, 200));
      }
      controller.close();
    },
    cancel(reason) {
      console.log("stream cancelled:", reason);
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Virheiden käsittely virran sisällä

Kun olette palauttaneet Response-olion, HTTP-tila on jo 200 ja otsakkeet on lähetetty. Ette voi vaihtaa tilaksi 500:tä kesken suoratoiston.

Ympäröikää siis riskialtis työ try/catch-rakenteella ja välittäkää virheet osana virtaa (esimerkiksi SSE:n event: error -rivi tai JSON-virheolio), minkä jälkeen sulkekaa virta. Käyttäkää controller.error(e)-kutsua vain, kun haluatte lopettaa virran äkillisesti.

// app/api/job/route.ts
export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const data = await riskyWork();
        controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"));
      } catch (err) {
        const msg = err instanceof Error ? err.message : "unknown";
        controller.enqueue(encoder.encode(JSON.stringify({ error: msg }) + "\n"));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "application/x-ndjson" },
  });
}

Edge-ajoympäristö ja vastapaine

Suoratoisto toimii erinomaisesti Edge-ajoympäristössä. Ottakaa se käyttöön komennolla export const runtime = "edge". Edge-ajoympäristö perustuu Web Streams -rajapintoihin, joten sama ReadableStream-koodi toimii muuttamattomana ja alkaa välittyä välittömästi käyttäjää lähellä sijaitsevasta sijainnista.

Vastapaine: jos asiakas lukee hitaasti, controller.enqueue puskuroi silti sisältöä. Jos tuottaja tuottaa paljon dataa, suosikaa pull-pohjaista lähdettä tai tarkistakaa controller.desiredSize tahdin säätämiseksi ja rajoittamattoman muistin kasvun välttämiseksi.

// app/api/edge-stream/route.ts
export const runtime = "edge";

export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 5; i++) {
        // desiredSize < 0 means the consumer is behind
        if ((controller.desiredSize ?? 1) > 0) {
          controller.enqueue(encoder.encode(`edge ${i}\n`));
        }
        await new Promise((r) => setTimeout(r, 100));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Virran kuluttaminen asiakkaalla

Selaimen puolella fetch antaa käyttöön response.body-olion, joka on itsessään ReadableStream. Lukekaa sitä lukijalla ja purkakaa osat niiden saapuessa, jotta käyttöliittymä päivittyy vaiheittain.

SSE:tä varten voitte käyttää sen sijaan natiivista EventSource-rajapintaa. Raakatekstille tai NDJSON:lle alla oleva lukusilmukka on yleiskäyttöinen ratkaisu.

// components/StreamReader.ts
export async function readStream(url: string, onChunk: (text: string) => void) {
  const res = await fetch(url);
  if (!res.body) throw new Error("No response body to stream");

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    onChunk(decoder.decode(value, { stream: true }));
  }
}

Pikatarkistus

Testatkaa ymmärrystänne suoratoistavista Route Handlers -toiminnoista.

Kertaus

Opitte suoratoistamaan vaiheittain saapuvaa dataa Next.js 15:n Route Handlers -toiminnoista:

  • ReadableStream yhdessä start(controller)-metodin sekä controller.enqueue()- ja controller.close()-kutsujen kanssa on keskeinen perusprimitiivi; palauttakaa se Response-olion sisällä.
  • async start mahdollistaa odottamisen osien välillä tekoälyn tokeneita, edistymislokeja ja SSE-tapahtumia varten.
  • Käyttäkää SSE:tä varten yhdistelmää text/event-stream + data: ...\n\n tai rivinvaihtoon perustuvaan JSONiin NDJSON-muotoa.
  • Tarkkailkaa yhteyden katkeamista varten arvoa req.signal.aborted ja vapauttakaa resurssit cancel()-kutsussa.
  • Tila ja otsakkeet lukitaan palautuksen yhteydessä, joten raportoi kesken suoratoiston ilmenevät virheet osina virtaa.
  • Edge-ajoympäristö (runtime = "edge") suorittaa samaa Web Streams -koodia; huomioikaa vastapaine arvon desiredSize avulla.
Aloita maksutta

Opi TypeScript tekoälytuutorin avulla — ilmaiseksi

Kirjoita ja suorita oikeaa koodia selaimessa, saa välitöntä apua tekoälytuutorilta ympäri vuorokauden ja jatka siitä, mihin jäit, verkossa tai sovelluksessa.

Kurssit
22
Oppitunnit
88

Usein kysytyt kysymykset

Onko oppitunti ”Vastausten suoratoisto ja ReadableStream-käsittelijöissä” ilmainen?

Kyllä — voit lukea täällä verkossa kokonaan ilmaiseksi mitkä tahansa Next.js 15 -fullstack-kehitys (App Router + Server Actions)-oppimispolun 3 oppituntia, myös oppitunnin “Vastausten suoratoisto ja ReadableStream-käsittelijöissä”. Sen jälkeen CoddyKit PRO avaa kaikki oppitunnit sekä interaktiiviset harjoitukset sisäänrakennetulla koodieditorilla ja ympäri vuorokauden toimivalla tekoälytuutorilla. Next.js 15 -fullstack-kehitys (App Router + Server Actions)-kurssilla on yhteensä 4 oppituntia.

Mitä opin oppitunnilla ”Vastausten suoratoisto ja ReadableStream-käsittelijöissä”?

Palauttakaa dataa vähitellen ReadableStreamilla esimerkiksi tekoälytokeneita, lokeja ja vaiheittain muodostuvia hyötykuormia varten. Harjoittelet Next.js 15 -fullstack-kehitys (App Router + Server Actions)-aihetta koodilla, jonka suoritat suoraan selaimessa. Ympäri vuorokauden käytettävissä oleva tekoälytuutori vastaa kysymyksiisi oppitunnin aikana.

Tarvitsenko kokemusta aloittaakseni Next.js 15 -fullstack-kehitys (App Router + Server Actions)-opiskelun?

Aiempi kokemus ei ole tarpeen. CoddyKitin Next.js 15 -fullstack-kehitys (App Router + Server Actions)-oppimispolku sopii vasta-alkajista edistyneisiin, joten voit aloittaa tästä tai alusta ja edetä omaan tahtiisi. Tämä on oppitunti 3/4.

Kuinka kauan ”Vastausten suoratoisto ja ReadableStream-käsittelijöissä”-oppitunnin suorittaminen kestää?

Useimmat CoddyKitin oppitunnit kestävät noin 5–10 minuuttia. Jokainen oppitunti on lyhyt ja interaktiivinen, joten edistyt tasaisesti ja voit jatkaa siitä, mihin jäit – sekä verkossa että sovelluksessa.

Voinko kirjoittaa ja suorittaa koodia tällä Next.js 15 -fullstack-kehitys (App Router + Server Actions)-oppitunnilla?

Kyllä. Jokainen Next.js 15 -fullstack-kehitys (App Router + Server Actions)-oppitunti sisältää sisäänrakennetun koodieditorin, joten voit kirjoittaa ja suorittaa oikeaa koodia suoraan selaimessa ja saada välitöntä palautetta tekoälyltä – paikallista asennusta ei tarvita.

Kaikki tämän kurssin oppitunnit

  1. RESTful-reittikäsittelijöiden suunnittelu Web Request APIlla
  2. Node-ajonaikainen ympäristö ja Edge-ajonaikainen ympäristö
  3. Vastausten suoratoisto ja ReadableStream-käsittelijöissä
  4. Pyyntöjen validointi ja tyypitetyt JSON-vastaukset Zodilla
← Takaisin: Next.js 15 -fullstack-kehitys (App Router + Server Actions)