纯前端用 Next.js 本地实现 SSE 流式传输

105 阅读1分钟

很多同学已经熟练使用 AI 辅助编程了,但直觉在本地搭建一个轻便的 SSE “打字”输出的效果并不好弄。有很多教程需要依赖 ExpressNode 来监听端口,或者使用 Mock Service Worker (MSW),总之都要依赖点什么,一旦中间某处卡住了,直接“未开始便放弃了”。其实,只需要 Next.js 就够了。

image.png

框架与依赖

框架

Next.js (支持 App router 的版本)

依赖(非必须)

@microsoft/fetch-event-source(直接使用原生的 EventSource 也可以)

思路

用 Next.js 中的 API route 模拟一个 text/event-stream 头的返回

代码

API route 部分

// 存到这里 src/app/api/sse/route.ts

const EVENTS = {
  MESSAGE: "message",
  ERROR: "error",
  DONE: "done",
}
const WHEN_ERROR = 5;
const WHEN_DONE = 10;

export async function GET() {
  const encoder = new TextEncoder();
  let interval: NodeJS.Timeout;

  const stream = new ReadableStream({
    start(controller) {
      let count = 0;
      let payload;
      interval = setInterval(() => {
        count++;
        payload = {
          time: new Date().toISOString(),
          count,
          event: count === WHEN_DONE ? EVENTS.DONE : EVENTS.MESSAGE
        };
        
        // uncomment below lines to test error
        // if (count === WHEN_ERROR) {
        //   payload.event = EVENTS.ERROR;
        // }

        // replace data in chunk with your split contents
        const chunk = `id: ${payload.count}\nevent: ${payload.event}\ndata: ${JSON.stringify(payload)}\n\n`;
        controller.enqueue(encoder.encode(chunk));
        if ([EVENTS.ERROR,EVENTS.DONE].includes(payload.event)) {
          clearInterval(interval);
          controller.close();
        }
      }, 500);
    },
    cancel() {
      // cleanup when the stream is canceled
      clearInterval(interval);
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
      // 'X-Accel-Buffering': 'no', // helpful for some proxies
    },
  });
}

页面部分

// 存到这里 src/app/page.tsx

import { useEffect, useState } from "react";
import { fetchEventSource } from '@microsoft/fetch-event-source';

// 此处代码省略

useEffect(()=>{
    const controller = new AbortController();
    const { signal } = controller;
    const getStream = async()=>{
      console.log("SSE starts")
      await fetchEventSource('/api/sse', {
        signal,
        async onopen(response) {
            if (response.ok) {
                console.log("SSE connected")
                return;
            } else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
                // client-side errors are usually non-retriable
                throw new Error("SSE FatalError");
            } else {
                throw new Error("SSE RetriableError");
            }
        },
        onmessage(ev) {
            const data = JSON.parse(ev.data);
            console.log("SSE data: ", data);
            if (data.event === "error" ) {
              controller.abort();
              console.log("SSE error");
            }
            if (data.event === "done" ) {
              console.log("SSE done");
            }
            if (["done", "error"].includes(data.event)) {
              console.log("SSE closed");
              return;
            }
            // setMessages(prev => [...prev,data])
        },
        onerror(err){
          console.log("SSE error: ", err)
        }
      });
    };
    getStream();
  }, []);
  
  // 此处代码省略

结果

跑起来后,如果看到以下 console.log 就成功了。 Have fun :)

image.png