Next.js v14 如何实现 SSE、接入 ChatGPT Stream?

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

《Next.js App Router + Socket.IO 实现简易聊天室》中,我们讲解了 Next.js 如何结合 WebSocket 实现实时聊天功能。这种服务端向客户端推送数据的需求,除了使用 WebSocket,还可以借助 SSE(Server-Sent Events)。

SSE 最近被广泛提起,主要是因为 ChatGPT 的 Stream 接口用的正是 SSE 规范。

本篇会:

  1. 介绍 SSE 的基本概念和用法
  2. Next.js 如何实现 SSE 以及客户端如何调用?
  3. Next.js 如何调用 ChatGPT Stream 接口?

PS:本篇已收录到掘金专栏《Next.js 开发指北》

PPS:系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

SSE 介绍

SSE 基于 HTTP 协议,严格来说,HTTP 协议并不能做到服务端主动推送消息。之所以能实现,是因为服务端将发送的信息声明为流(Stream),告诉客户端内容会源源不断的发送过来,这时客户端并不会关闭连接,而是会一直等待服务端发送新的数据。SSE 便是利用这种机制实现"主动"推送消息。

它与 WebSocket 不同的是:

  1. SSE 使用 HTTP 协议,WebSocket 是独立的 ws 协议
  2. SSE 依然是单向通信,只能服务端向客户端发送,而 WebSocket 是双向通信
  3. SSE 属于轻量级,使用简单;WebSocket 协议相对复杂
  4. SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据
  5. ......

SSE 的一个常见应用就是 AI 的打字流效果。这并不是为了效果酷炫而特地做的效果,而是因为数据本身就是流式传输。像 ChatGPT 的 Stream 数据用的正是 SSE 的方式。

EventSource 介绍

浏览器提供了 EventSource API 用于建立 SSE 连接。使用方法如下:

javascript 复制代码
const evtSource = new EventSource("//api.example.com/ssedemo.php", {
  withCredentials: true,
});

evtSource.onmessage = function (event) {
  var data = event.data;
  // handle message
};

// 另一种写法
evtSource.addEventListener('message', function (event) {
  var data = event.data;
  // handle message
}, false);

// 其他方法还有:
evtSource.onopen = function (event) {
  // ...
};

evtSource.onerror = function (event) {
  // ...
};

// 关闭 SSE 连接
evtSource.close();

那服务端该怎么实现呢?

首先,服务器向浏览器发送的 SSE 数据,必须是 UTF-8 编码的文本,且具有如下 HTTP 头信息:

javascript 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

最重要的是第一句:Content-Type 必须指定 MIME 类型为 event-steamCache-Control: no-cache 防止浏览器缓存。Connection: keep-alive 保持连接打开以进行流式传输。

其次,是发送的数据的格式。每一次发送的信息,由若干个 message 组成,每个 message 之间用\n\n分隔。每个 message 内部由若干行组成,每一行都是如下格式:

bash 复制代码
[field]: value

field 具体有 4 个字段,常用的有 data 和 event,比如

markdown 复制代码
data: some text\n\n

data: another message\n
data: with two lines \n\n

这就是发送了 2 次数据,一次数据是 some text,一次是 another message\nwith two lines

又比如:

bash 复制代码
event: foo\n
data: a foo event\n\n

data: an unnamed event\n\n

event: bar\n
data: a bar event\n\n

这次携带了 event 字段,表示触发的事件名。如果没有 event,通过 message 事件即可监听。但如果携带了 event 字段,则需要监听对应的事件名,就比如 event: foo,客户端代码需要写成如下形式才能监听:

javascript 复制代码
evtSource.addEventListener('foo', function (event) {
  // ...
}, false);

Express 实现 SSE

为了帮助大家更好的理解 SSE 的实现,我们使用 Express 实现一个服务端和客户端连接的例子。

运行以下命令,创建一个项目:

bash 复制代码
# 建立项目文件夹
mkdir express-sse && cd express-sse
# 项目初始化
npm init
# 安装依赖项
npm i express

新建 app.js,代码如下:

javascript 复制代码
const express = require('express')
const app = express()

const port = 8000

app.get('/', (req, res) => {
  console.log('Client connected')
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Access-Control-Allow-Origin', '*')

  const intervalId = setInterval(() => {
    const date = new Date().toLocaleString()
    res.write(`data: ${date}\n\n`)
  }, 1000)

  res.on('close', () => {
    console.log('Client closed connection')
    clearInterval(intervalId)
    res.end()
  })
})

app.listen(port, () => {
  console.log(`Server running on port ${port}`)
})

新建 index.html,代码如下:

html 复制代码
<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSE client</title>
  </head>

  <body>
    <div id="messages"></div>
    <script>
      const eventSource = new EventSource('http://localhost:8000')

      function updateMessage(message) {
        const list = document.getElementById('messages')
        const item = document.createElement('p')
        item.textContent = message
        list.appendChild(item)
      }

      eventSource.onmessage = function (event) {
        updateMessage(event.data)
      }

      eventSource.onerror = function () {
        updateMessage('Server closed connection')
        eventSource.close()
      }
    </script>
  </body>

</html>

命令行运行node app.js,打开 http://localhost:8000/,效果如下:

因为持续保持连接,所以页面一直处于加载中(Tab 中的 loading 图标一直在转)。

浏览器本地直接打开我们刚创建的 index.html 文件,效果如下:

查看接口信息,可以看到接口的加载时间随着信息的返回一直在更新。

Next.js 实现 SSE

在 Next.js 中又该如何实现 SSE 呢?

1. 创建项目

运行 npx create-next-app@latest 创建 Next.js 项目:

2. 示例:ReadableStream

新建 app/api/sse1/route.js,代码如下:

javascript 复制代码
export const dynamic = "force-dynamic";

let interval;
export async function GET(request) {
  const encoder = new TextEncoder()

  // 创建一个 Streaming Response
  const customReadable = new ReadableStream({
    start(controller) {
      interval = setInterval(() => {
        const message = new Date().toLocaleString()
        controller.enqueue(encoder.encode(`data: ${message}\n\n`))
      }, 1000)
    },
    cancel() {
      clearInterval(interval);
    }
  })

  return new Response(customReadable, {
    // 设置 Server-Sent Events (SSE) 相关的 headers 
    headers: {
      "Connection": "keep-alive",
      "Content-Encoding": "none",
      "Cache-Control": "no-cache, no-transform",
      "Content-Type": "text/event-stream; charset=utf-8",
    },
  })
}

这就是一个最简单的示例,使用 ReadableStream 创建了一个只读 Stream,然后返回 Response 的时候设置了 SSE 相关的 header 头。

新建 app/sse1/page.js,代码如下:

jsx 复制代码
'use client';

import { useEffect, useState } from "react";

export default function Home() {

  const [text, setText] = useState('')

  useEffect(() => {
    const eventSource = new EventSource('/api/sse1')
    eventSource.onmessage = function (event) {
      console.log('Received message:', event.data)
      setText((pre) => pre + event.data);
    }

    return () => {
      if (eventSource.readyState != 2) {
        eventSource.close()
      }
    }
  }, [])

  return (
    <p>{text}</p>
  );
}

浏览器效果如下:

3. 示例:TransformStream

稍微复杂的场景可能会用到 TransformStream,所以我们再写一个 TransformStream 示例。

新建 app/api/sse2/route.js,代码如下:

javascript 复制代码
export async function GET(request) {
  const encoder = new TextEncoder()
  // 创建 TransformStream
  const stream = new TransformStream({
    transform(chunk, controller) {
      controller.enqueue(chunk)
    },
  })
  const writer = stream.writable.getWriter()

  const sseData = `:ok\n\nevent: message\ndata: Initial message\n\n`
  writer.write(encoder.encode(sseData))

  // 定义一个计数器
  let counter = 0

  // 每秒发送一个消息
  const interval = setInterval(() => {
    counter++

    if (counter > 10) {
      clearInterval(interval)
      return
    }

    const message = `event: message\ndata: Message ${counter}\n\n`
    writer.write(encoder.encode(message))
  }, 1000)

  request.signal.addEventListener("abort", async () => {
    console.log("abort");
    await writer.ready
    await writer.close();
    clearInterval(interval)
  });

  // 创建 SSE 响应
  let response = new Response(stream.readable)

  // 设置响应头,指定使用 SSE
  response.headers.set('Content-Type', 'text/event-stream')
  response.headers.set('Cache-Control', 'no-cache')
  response.headers.set('Connection', 'keep-alive')

  return response
}

新建 app/sse2/page.js,代码如下:

jsx 复制代码
'use client';

import { useEffect, useState } from "react";

export default function Home() {

  const [text, setText] = useState('')

  useEffect(() => {
    const eventSource = new EventSource('/api/sse2')

    eventSource.addEventListener('message', function (event) {
      console.log('Received message:', event.data)
      setText((pre) => pre + event.data);
    }, false);

    eventSource.onerror = function () {
      console.log("EventSource failed.");
    };

    return () => {
      if (eventSource.readyState != 2) {
        eventSource.close()
      }
    }
  }, [])
  
  return (
    <p>{text}</p>
  );
}

浏览器效果如下:

4. 使用 fetch-event-source

在实际开发中,使用 EventSource 有一些限制,比如 EventSource 只支持 GET 请求,且不支持自定义头部。如果满足需求倒也可以,但比如像 chatGPT 的接口就是 POST 请求而且还要携带 Token 参数,这个时候处理就比较麻烦了。

所以在实际开发中,很多人会使用 fetch-event-source 来作为 SSE 客户端 API。

安装依赖项:

javascript 复制代码
npm install @microsoft/fetch-event-source

fetch-event-source 的基本用法如下:

javascript 复制代码
// BEFORE:
const sse = new EventSource('/api/sse');
sse.onmessage = (ev) => {
    console.log(ev.data);
};

// AFTER:
import { fetchEventSource } from '@microsoft/fetch-event-source';

await fetchEventSource('/api/sse', {
    onmessage(ev) {
        console.log(ev.data);
    }
});

我们写一个完整示例。新建 api/sse3/route.js,代码如下:

javascript 复制代码
export async function POST(request) {
  const encoder = new TextEncoder()
  // 创建 TransformStream
  const stream = new TransformStream({
    transform(chunk, controller) {
      controller.enqueue(chunk)
    },
  })
  const writer = stream.writable.getWriter()

  const sseData = `:ok\n\nevent: message\ndata: Initial message\n\n`
  writer.write(encoder.encode(sseData))

  // 定义一个计数器
  let counter = 0

  // 每秒发送一个消息
  const interval = setInterval(() => {
    counter++

    if (counter > 10) {
      clearInterval(interval)
      return
    }

    const message = `event: message\ndata: Message ${counter}\n\n`
    writer.write(encoder.encode(message))
  }, 1000)

  request.signal.addEventListener("abort", async () => {
    console.log("abort");
    await writer.ready
    await writer.close();
    clearInterval(interval)
  });

  // 创建 SSE 响应
  let response = new Response(stream.readable)

  // 设置响应头,指定使用 SSE
  response.headers.set('Content-Type', 'text/event-stream')
  response.headers.set('Cache-Control', 'no-cache')
  response.headers.set('Connection', 'keep-alive')

  return response
}

相比于 /api/sse2/route.js,其实就只是将 GET 方法改为了 POST。

新建 app/sse3/page.js,代码如下:

jsx 复制代码
'use client';

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

export default function Home() {

  const [text, setText] = useState('')

  useEffect(() => {
    const ctrl = new AbortController();
    fetchEventSource('/api/sse3', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        foo: 'bar'
      }),
      signal: ctrl.signal,
      onmessage: function (event) {
        console.log('Received message:', event.data)
        setText((pre) => pre + event.data);
      }
    });

    return () => {
      ctrl.abort()
    }
  }, [])

  return (
    <p>{text}</p>
  );
}

打开 http://localhost:3000/sse3,浏览器效果如下:

虽然效果相同,但查看请求的类型。之前是:

现在是:

所以 fetch-event-source 本质是使用了 fetch 请求来模拟 EventSource 的处理。

5. 模拟 fetch-event-source

那 fetch-event-source 是怎么处理的呢?我们可以自己手写一个 fetch-event-source 试着模拟一下。

新建 app/sse4/page.js,代码如下:

javascript 复制代码
'use client';

const fetchEventSource = (url, params) => {
  const { onmessage, onclose, ...otherParams } = params;

  const push = async (controller, reader) => {
    const { value, done } = await reader.read();
    if (done) {
      controller.close();
      onclose?.();
    } else {
      const parsedValue = new TextDecoder().decode(value)
      onmessage?.(parsedValue);
      controller.enqueue(value);
      push(controller, reader);
    }
  };

  return fetch(url, otherParams)
    .then((response) => {
      const reader = response.body.getReader();
      const stream = new ReadableStream({
        start(controller) {
          push(controller, reader);
        },
      });
      return stream;
    })
    .then((stream) => new Response(stream, { headers: { 'Content-Type': 'text/html' } }).text());
};

import { useEffect, useState } from "react";

export default function Home() {

  const [text, setText] = useState('')

  useEffect(() => {
    const ctrl = new AbortController();
    fetchEventSource('/api/sse3', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        foo: 'bar'
      }),
      signal: ctrl.signal,
      onmessage: function (event) {
        console.log('Received message:', event)
        setText((pre) => pre + event);
      }
    });

    return () => {
      ctrl.abort('React unmoute cancel')
    }
  }, [])

  return (
    <p>{text}</p>
  );
}

打开 http://localhost:3000/sse4,效果如下:

客户端依然调用之前的 api/sse3接口,虽然是 fetch 请求,但依然实现了 Stream 效果。不过与 EventSource 不同的是,这里并没有对数据格式进行解析。实际开发中,还是建议直接使用 fetch-event-source,健壮性更好。这个例子只是用于理解和学习。

6. 前端调用 ChatGPT Stream 接口

ChatGPT 的接口就支持 Stream 格式。如果在 Next.js 中获取 Steam 格式的接口数据呢?这又分为两种情况:

  1. 前端直接调用 ChatGPT 的 Stream 接口
  2. 后端声明一个接口,调用 ChatGPT 的 Stream 接口。前端页面调用此接口。这样做的好处在于不暴露敏感信息。

我们先说直接调用的方式。新建 app/sse5/page.js,代码如下:

jsx 复制代码
'use client';

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

export default function Home() {

  const [text, setText] = useState('')

  useEffect(() => {
    const ctrl = new AbortController();

    fetchEventSource('https://api.openai-proxy.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      },
      body: JSON.stringify({
        model: 'gpt-3.5-turbo',
        messages: [
          {
            role: 'user',
            content: '如何学习 JavaScript?'
          }
        ],
        stream: true
      }),
      signal: ctrl.signal,
      async onmessage(event) {
        if (event.data === '[DONE]') {
          console.log('Done')
          return
        }
        const jsonData = JSON.parse(event.data)
        if (jsonData.choices[0].finish_reason === 'stop') {
          return
        }

        const text = jsonData.choices[0]?.delta.content
        setText((value) => {
          return value + text
        })
      },
      async onerror(error) {
        console.error('Error:', error)
      },
      async onclose() {
        console.log('Close')
      }
    })

    return () => {
      ctrl.abort()
    }
  }, [])

  return (
    <p>{text}</p>
  );
}

因为 ChatGPT 的 Stream 接口遵循的就是 SSE 规范,所以客户端可以直接使用 fetchEventSource 调用。

打开 http://localhost:3000/sse5,浏览器效果如下:

7. 后端代理 ChatGPT Stream 接口

新建 next-sse/app/api/sse6/route.js,代码如下:

javascript 复制代码
import OpenAI from 'openai';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const openai = new OpenAI({
  apiKey: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  baseURL: "https://api.openai-proxy.com/v1"
});

const encoder = new TextEncoder()

async function* makeIterator(response) {
  for await (const chunk of response) {
    const delta = chunk.choices[0].delta.content

    yield encoder.encode(delta)
  }
}

function iteratorToStream(iterator) {
  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next()

      if (done) {
        controller.close()
      } else {
        controller.enqueue(value)
      }
    },
  })
}

export async function GET() {
  const response = await openai.chat.completions.create({
    model: 'gpt-3.5-turbo',
    stream: true,
    messages: [{ role: "user", content: "如何学习 JavaScript" }],
  });

  return new Response(iteratorToStream(makeIterator(response)))
}

新建 next-sse/app/sse6/page.js,代码如下:

jsx 复制代码
'use client';

import { fetchEventSource } from '@microsoft/fetch-event-source';
import { useEffect, useState } from "react";
const decoder = new TextDecoder('utf-8');

export default function Home() {
  const [text, setText] = useState('')

  useEffect(() => {
    const ctrl = new AbortController();

    const fetchData = async () => {
      const response = await fetch("http://localhost:3000/api/sse6", {
        method: "GET",
        signal: ctrl.signal
      });
      return response
    }

    const getData = async () => {
      const response = await fetchData()
      const reader = response.body.getReader();

      reader.read().then(function process({ done, value }) {
        if (done) {
          console.log('Stream finished');
          return;
        }
        const text = decoder.decode(value);
        console.log('Received data chunk', text);

        setText((value) => {
          return value + text
        })

        return reader.read().then(process);
      });
    }

    getData()

    return () => {
      ctrl.abort('cancel')
    }
  }, [])

  return (
    <p>{text}</p>
  );
}

打开 http://localhost:3000/sse6,浏览器效果如下:

在路由处理程序中,我们手动实现了迭代器来处理返回的流数据,具体的原理可以参考之前写的《如何用 Next.js v14 实现一个 Streaming 接口?》

如果你想要将 ChatGPT 的流数据直接返回给客户端,你也可以:

javascript 复制代码
export async function POST(req) {
  const response = await openai.chat.completions.create({
    model: 'gpt-3.5-turbo',
    stream: true,
    messages: [{ role: "user", content: "如何学习 JavaScript" }],
    ]});

  const stream = response.toReadableStream();
  return new Response(stream);
}

或是接入 Vercel 的 ai 包:

javascript 复制代码
import { OpenAIStream, StreamingTextResponse } from 'ai';

export async function POST() {
  const response = await openai.chat.completions.create({
    model: 'gpt-3.5-turbo',
    stream: true,
    messages: [{ role: "user", content: "如何学习 JavaScript" }],
  });

  const stream = OpenAIStream(response);
  return new StreamingTextResponse(stream);
}

参考链接

  1. www.ruanyifeng.com/blog/2017/0...
  2. github.com/renjithspac...
  3. developer.mozilla.org/zh-CN/docs/...
  4. dev.viku.org/create-serv...
  5. juejin.cn/post/721578...
相关推荐
类人_猿1 小时前
ASP.NET Web(.Net Framework) Http服务器搭建以及IIS站点发布
前端·iis·asp.net·.net·http站点服务器
组态软件4 小时前
web组态软件
前端·后端·物联网·编辑器·html
前端Hardy4 小时前
HTML&CSS:MacBook Air 3D 动画跃然屏上
前端·javascript·css·3d·html
loey_ln5 小时前
观察者模式和发布订阅模式
javascript·观察者模式·react.js
cnsxjean7 小时前
SpringBoot集成Minio实现上传凭证、分片上传、秒传和断点续传
java·前端·spring boot·分布式·后端·中间件·架构
ZL_5677 小时前
uniapp中使用uni-forms实现表单管理,验证表单
前端·javascript·uni-app
沉浮yu大海8 小时前
Vue.js 组件开发:构建可重用且高效的 UI 块
前端·vue.js·ui
代码欢乐豆8 小时前
软件工程第13章小测
服务器·前端·数据库·软件工程
sunly_8 小时前
Flutter:启动屏逻辑处理02:启动页
android·javascript·flutter
EasyNTS8 小时前
H5流媒体播放器EasyPlayer.js网页直播/点播播放器如果H.265视频在播放器上播放不流畅,可以考虑的解决方案
javascript·音视频·h.265