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 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax