本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
在《Next.js App Router + Socket.IO 实现简易聊天室》中,我们讲解了 Next.js 如何结合 WebSocket 实现实时聊天功能。这种服务端向客户端推送数据的需求,除了使用 WebSocket,还可以借助 SSE(Server-Sent Events)。
SSE 最近被广泛提起,主要是因为 ChatGPT 的 Stream 接口用的正是 SSE 规范。
本篇会:
- 介绍 SSE 的基本概念和用法
- Next.js 如何实现 SSE 以及客户端如何调用?
- Next.js 如何调用 ChatGPT Stream 接口?
PS:本篇已收录到掘金专栏《Next.js 开发指北》 。
PPS:系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
SSE 介绍
SSE 基于 HTTP 协议,严格来说,HTTP 协议并不能做到服务端主动推送消息。之所以能实现,是因为服务端将发送的信息声明为流(Stream),告诉客户端内容会源源不断的发送过来,这时客户端并不会关闭连接,而是会一直等待服务端发送新的数据。SSE 便是利用这种机制实现"主动"推送消息。
它与 WebSocket 不同的是:
- SSE 使用 HTTP 协议,WebSocket 是独立的 ws 协议
- SSE 依然是单向通信,只能服务端向客户端发送,而 WebSocket 是双向通信
- SSE 属于轻量级,使用简单;WebSocket 协议相对复杂
- SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据
- ......
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-steam
。Cache-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 格式的接口数据呢?这又分为两种情况:
- 前端直接调用 ChatGPT 的 Stream 接口
- 后端声明一个接口,调用 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);
}