WebSocket
WebSocket 是一种在客户端和服务端之间建立持久双向连接的通信协议。HTTP 是"一问一答",WebSocket 是"随时互发"——服务端可以主动推数据给客户端,不需要客户端先请求。
为什么需要 WebSocket?
| 场景 | HTTP | WebSocket |
|---|---|---|
| 聊天消息 | 客户端需要不断轮询"有新消息吗?" | 服务端有消息直接推过去 |
| 实时通知 | 延迟高,可能错过 | 即时送达 |
| 协同编辑 | 需要频繁请求同步 | 双方实时同步 |
| 状态监控 | 每隔 N 秒请求一次 | 数据变了立即推送 |
一句话理解
HTTP 像发邮件(你问我才答),WebSocket 像打电话(双方随时说话)。
Nuxt 中的 WebSocket
Nuxt 基于 Nitro 引擎,Nitro 提供了跨平台 WebSocket 支持(基于 CrossWS)。你需要在配置中启用 WebSocket,然后在 server/ 目录下创建路由文件,使用 defineWebSocketHandler 即可。
启用 WebSocket
在 nuxt.config.ts 中启用 WebSocket 支持:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
websocket: true, // Nitro v2 使用 experimental
},
},
})Nitro v3 变化
Nitro v3 将 websocket 从 experimental 移到了 features,配置变为 nitro.features.websocket: true。当前 Nuxt 4 使用的 Nitro v2 仍用 experimental。
基本结构
// server/api/_ws.ts
export default defineWebSocketHandler({
open(peer) {
// 客户端连接时触发
console.log('连接建立', peer.id)
},
message(peer, message) {
// 收到客户端消息时触发
console.log('收到消息', message.text())
},
close(peer, details) {
// 连接关闭时触发
console.log('连接关闭', details.code, details.reason)
},
error(peer, error) {
// 发生错误时触发
console.error('连接错误', error)
},
})INFO
️ 文件路径决定 WebSocket 端点路径
server/api/_ws.ts→ 连接地址为ws://localhost:3000/api/_wsserver/api/chat.ts→ 连接地址为ws://localhost:3000/api/chat- 文件名以
_开头只是惯例,表示"内部"端点,不是必须的
客户端连接
// 浏览器端
const ws = new WebSocket('ws://localhost:3000/api/_ws')
ws.onopen = () => {
console.log('已连接')
ws.send('你好,服务端!')
}
ws.onmessage = (event) => {
console.log('收到消息', event.data)
}
ws.onclose = () => {
console.log('连接已关闭')
}Hooks
defineWebSocketHandler 支持以下生命周期钩子:
upgrade
在 WebSocket 连接建立之前调用。用于认证请求、附加上下文数据(通过 request.context)。
INFO
️ 版本差异:Nitro v2(Nuxt 4 当前使用)基于 crossws 0.3.x upgrade 钩子只能返回 Response | ResponseInit | void,不能通过返回值设置 context 或 namespace。Nitro v3 基于 crossws 0.4.x,支持 return { context, namespace, headers }
export default defineWebSocketHandler({
upgrade(request) {
const url = new URL(request.url)
const token = url.searchParams.get('token')
if (!isValidToken(token)) {
// 抛出 Response 可以拒绝连接
throw new Response('Unauthorized', { status: 401 })
}
// 通过 request.context 设置上下文数据(后续可通过 peer.context 访问)
request.context.userId = getUserId(token)
},
open(peer) {
console.log('用户连接:', peer.context.userId)
},
})upgrade 钩子的返回值(Nitro v2 / crossws 0.3.x)只能是 Response | ResponseInit | void:
- 返回
void(或不返回):正常完成升级 - 抛出
Response:拒绝升级请求(如认证失败) - 返回
ResponseInit:自定义升级响应的状态码或响应头
INFO
️ 设置上下文数据:在 Nitro v2 中 upgrade 钩子不能通过返回值设置 context 或 namespace。设置上下文数据请通过 request.context(见下方说明),命名空间由文件路径决定
Nitro v3 变化
Nitro v3(crossws 0.4.x)的 upgrade 钩子支持返回 { headers, namespace, context } 对象,可以直接通过返回值设置上下文和命名空间。
open
WebSocket 连接建立后触发,peer 可以正常收发消息。
message
收到客户端消息时触发。
close
连接关闭时触发,接收 details 对象,包含 code 和 reason。
error
连接发生错误时触发。
Peer 对象
peer 代表一个 WebSocket 连接(一个客户端),是最核心的对象:
属性
| 属性 | 类型 | 说明 |
|---|---|---|
peer.id | string | 连接唯一标识(UUID v4,自动生成) |
peer.context | object | 自定义上下文数据(通过 upgrade 钩子中的 request.context 设置) |
peer.request | UpgradeRequest | 原始的 WebSocket 升级请求(部分运行时可能不可用) |
peer.peers | Set<Peer> | 所有已连接的 peer(不限于同一命名空间) |
peer.topics | Set<string> | 该连接订阅的所有主题 |
peer.remoteAddress | string? | 客户端 IP 地址(部分运行时不可用) |
peer.websocket | Partial<WebSocket> | 底层 WebSocket 实例(通过代理包装,增强兼容性,部分属性可能不可用) |
方法
| 方法 | 说明 |
|---|---|
peer.send(data, options?) | 给这个客户端发送消息(支持字符串、对象自动序列化为 JSON、二进制数据)。options.compress 可启用压缩 |
peer.close(code?, reason?) | 主动关闭连接 |
peer.subscribe(topic) | 订阅一个主题 |
peer.unsubscribe(topic) | 取消订阅 |
peer.publish(topic, data, options?) | 向某个主题的其他订阅者发送消息(不包括自己)。options.compress 可启用压缩 |
peer.terminate() | 立即终止连接(不发送关闭帧) |
peer.context 是你的"便签纸"
在 upgrade 钩子中通过 request.context 设置上下文,在后续钩子中通过 peer.context 读取:
upgrade(request) {
const url = new URL(request.url)
const userId = url.searchParams.get('userId')
request.context.userId = userId // 通过 request.context 设置
},
open(peer) {
console.log(peer.context.userId, '已连接') // 通过 peer.context 读取
}消息格式
发送和接收
export default defineWebSocketHandler({
message(peer, message) {
// 接收文本消息
const text = message.text() // "hello"
// 接收并解析 JSON
const data = message.json() // 自动 JSON.parse
// 接收二进制消息
const bytes = message.uint8Array() // Uint8Array
// 发送文本
peer.send('收到!')
// 发送 JSON 对象(自动序列化)
peer.send({ type: 'pong', time: Date.now() })
},
})Message 对象
message 对象除了方法外,还有一些有用的属性:
| 属性 | 类型 | 说明 |
|---|---|---|
message.id | string | 消息唯一标识(UUID v4) |
message.peer | Peer | 发送该消息的 peer 实例 |
message.rawData | any | 原始消息数据 |
Message 对象方法
| 方法 | 返回类型 | 说明 |
|---|---|---|
message.text() | string | 消息的 UTF-8 字符串 |
message.json() | T | 消息解析为 JSON |
message.uint8Array() | Uint8Array | 消息的字节数组 |
message.arrayBuffer() | ArrayBuffer | 消息的 ArrayBuffer |
message.blob() | Blob | 消息的 Blob |
约定 JSON 消息格式
实际项目中,建议统一使用 JSON 格式通信,约定 type 字段区分消息类型:
// server/api/_ws.ts
export default defineWebSocketHandler({
message(peer, message) {
try {
const data = message.json<{ type: string; content?: string }>()
switch (data.type) {
case 'ping':
peer.send({ type: 'pong' })
break
case 'chat':
peer.send({ type: 'chat_reply', content: '已收到' })
break
default:
peer.send({ type: 'error', message: '未知的消息类型' })
}
} catch {
peer.send({ type: 'error', message: '消息格式错误,需要 JSON' })
}
},
})推荐所有消息都用 JSON + type 字段
比纯文本更清晰,方便扩展。客户端和服务端按 type 分发处理逻辑。
发布/订阅(PubSub)
WebSocket 最常用的模式是"发布/订阅"——把连接分组,给某个组广播消息:
基本用法
export default defineWebSocketHandler({
open(peer) {
// 连接时加入"聊天室1"
peer.subscribe('room:1')
},
message(peer, message) {
const data = message.json()
if (data.type === 'chat') {
// 给"聊天室1"的其他人发消息(不包括自己)
peer.publish('room:1', {
type: 'chat',
from: peer.context.username,
content: data.content,
})
// 如果也需要发给自己,用 peer.send
peer.send({ type: 'chat', from: peer.context.username, content: data.content })
}
},
close(peer) {
// 离开时自动取消订阅(Nitro 会自动处理,这里显式写出更清晰)
peer.unsubscribe('room:1')
},
})广播 vs 发给特定人
// 发给除自己外的所有订阅者
peer.publish('room:1', message)
// 同时也发给自己
peer.send(message)
// 发给某一个人
peer.send(message) // 只发给这个 peerpublish vs send
peer.publish(topic, data):发给订阅了该 topic 的其他连接(不包括自己)peer.send(data):只发给这一个连接
如果需要"包括自己"的广播效果,需要 publish + send 组合使用。
命名空间
INFO
️ 版本差异:命名空间(peer.namespace)是 Nitro v3(crossws 0.4.x)的功能 Nitro v2(crossws 0.3.x)没有命名空间概念,peer.publish() 会广播给所有订阅了同一 topic 的连接,不区分命名空间
Nitro v3 中:每个连接属于一个命名空间,peer.publish() 只广播给同一命名空间内的订阅者。默认命名空间由请求 URL 的路径决定,这对动态路由特别自然:
// server/api/rooms/[room].ts — Nitro v3:不同路径天然隔离
export default defineWebSocketHandler({
open(peer) {
peer.subscribe('messages')
peer.publish('messages', `${peer} 加入了房间`)
},
message(peer, message) {
// 只会发给同一房间的订阅者
peer.publish('messages', `${peer}: ${message.text()}`)
},
})Nitro v2 中:没有命名空间隔离,peer.publish() 会发给所有订阅了该 topic 的连接。如果需要按房间/频道隔离,请在 topic 名称中包含分组标识:
// server/api/_ws.ts — Nitro v2:通过 topic 名称模拟命名空间
export default defineWebSocketHandler({
upgrade(request) {
const url = new URL(request.url)
const room = url.searchParams.get('room') || 'general'
request.context.room = room
},
open(peer) {
// 用 topic 名称区分房间
peer.subscribe(`room:${peer.context.room}`)
peer.publish(`room:${peer.context.room}`, `${peer} 加入了房间`)
},
message(peer, message) {
peer.publish(`room:${peer.context.room}`, `${peer}: ${message.text()}`)
},
})Nitro v3 变化
v3 的 upgrade 钩子支持 return { namespace: 'custom' } 覆盖命名空间,且 peer.namespace 属性可用。v2 中命名空间不存在,需用 topic 名称模拟分组。
实际场景
| 场景 | 订阅 Topic | 发布时机 |
|---|---|---|
| 聊天室 | room:{roomId} | 有人发消息时 |
| 用户通知 | user:{userId} | 有新通知时 |
| 实时数据 | data:{dataType} | 数据更新时 |
| 系统广播 | system | 系统公告时 |
连接认证
WebSocket 协议没有内置认证机制,浏览器 WebSocket API 也不支持自定义请求头(如 Authorization),因此需要额外方案。
关于 Authorization 请求头
能否使用取决于客户端平台:
- ❌ 浏览器:
new WebSocket(url)不支持自定义请求头,这是浏览器 API 的限制 - ❌ Flutter Web:底层使用浏览器 WebSocket API,同样不支持
- ✅ Flutter 原生(Android/iOS/Desktop):
IOWebSocketChannel支持headers参数,可直接用Authorization头 - ✅ 其他原生客户端(Node.js、Python、Java 等):WebSocket 库通常都支持自定义头
因此,原生客户端首选 Authorization 头方案,浏览器/Web 客户端才需要 Ticket 或消息认证方案。
安全风险:URL 参数传 Token 的问题
最常见的做法是把 JWT 放在 URL 参数中:ws://host/api/_ws?token=xxx,但这有安全隐患:
| 风险 | 说明 |
|---|---|
| 服务器日志泄露 | URL 参数会被 Nginx/Node.js 访问日志完整记录,Token 明文暴露 |
| 浏览器历史记录 | URL 会出现在浏览器历史和地址栏自动补全中 |
| Referer 泄露 | 如果页面有外链,URL 中的 Token 可能通过 Referer 头泄露给第三方 |
| 代理/CDN 日志 | 中间代理也会记录完整 URL |
结论
开发环境用 URL 参数传 Token 可以接受,生产环境应使用更安全的方案。
方式一:Authorization 请求头(原生客户端首选)
如果客户端支持自定义 WebSocket 请求头(Flutter 原生、Node.js、Python 等),这是最简单最安全的方案——JWT 通过请求头传递,不会出现在 URL 中。
服务端:
// server/api/_ws.ts
export default defineWebSocketHandler({
upgrade(request) {
// 优先从 Authorization 头获取 token
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
// 兜底:从 URL 参数获取 ticket(供浏览器客户端使用)
const url = new URL(request.url)
const ticket = url.searchParams.get('ticket')
// 方式一:Authorization 头传 JWT
if (token) {
try {
const payload = verifyToken(token)
request.context.userId = payload.userId
} catch {
throw new Response('Invalid token', { status: 403 })
}
}
// 方式二:URL 参数传 Ticket(浏览器客户端)
if (ticket) {
const userId = consumeTicket(ticket)
if (!userId) {
throw new Response('Invalid or expired ticket', { status: 403 })
}
request.context.userId = userId
}
throw new Response('Missing authentication', { status: 401 })
},
open(peer) {
peer.subscribe(`user:${peer.context.userId}`)
peer.send({ type: 'connected', userId: peer.context.userId })
},
})Flutter 原生客户端(推荐):
import 'package:web_socket_channel/io.dart';
// 使用 IOWebSocketChannel 传入 Authorization 头
final channel = IOWebSocketChannel.connect(
Uri.parse('wss://your-server.com/api/_ws'),
headers: {
'Authorization': 'Bearer $jwt',
},
);为什么这是原生客户端的最佳方案?
- JWT 通过
Authorization头传递,不会出现在 URL、日志、浏览器历史中 - 无需额外的 Ticket API,一次连接直接完成
IOWebSocketChannel底层使用dart:io的WebSocket.connect,原生支持自定义 headers
INFO
️ Flutter Web 不支持:IOWebSocketChannel 仅适用于原生平台(Android/iOS/Desktop) Flutter Web 必须使用 WebSocketChannel.connect(不支持 headers),此时请用方式二的 Ticket 方案
方式二:短期一次性 Ticket(浏览器/Web 客户端首选)
核心思路:不要直接把长期 JWT 放在 URL 里,而是用 HTTP API 先换取一个短期一次性 Ticket,再用 Ticket 连接 WebSocket。适用于无法设置自定义请求头的客户端(浏览器、Flutter Web)。
1. 客户端 → HTTP POST /api/ws/ticket(带 Authorization: Bearer <jwt>)
2. 服务端验证 JWT,生成一次性 Ticket(短期、随机、可撤销)
3. 客户端 → WebSocket ws://host/api/_ws?ticket=<ticket>
4. 服务端验证 Ticket,标记已使用,建立连接服务端实现:
// server/api/ws/ticket.post.ts
import { randomUUID } from 'crypto'
// 存储活跃 ticket(生产环境应使用 Redis)
const tickets = new Map<string, { userId: number; expiresAt: number }>()
export default defineEventHandler(async (event) => {
// 必须已登录
const userId = event.context.userId
if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
// 生成一次性 Ticket,30 秒有效
const ticket = randomUUID()
tickets.set(ticket, { userId, expiresAt: Date.now() + 30_000 })
// 清理过期 ticket
for (const [key, value] of tickets) {
if (value.expiresAt < Date.now()) tickets.delete(key)
}
return { ticket }
})
// 供 WebSocket handler 调用的验证函数
export function consumeTicket(ticket: string): number | null {
const data = tickets.get(ticket)
if (!data) return null
tickets.delete(ticket) // 一次性使用,用完即删
if (data.expiresAt < Date.now()) return null // 已过期
return data.userId
}WebSocket handler 使用 Ticket:
// server/api/_ws.ts
import { consumeTicket } from './ws/ticket.post'
export default defineWebSocketHandler({
upgrade(request) {
const url = new URL(request.url)
const ticket = url.searchParams.get('ticket')
if (!ticket) {
throw new Response('Missing ticket', { status: 401 })
}
const userId = consumeTicket(ticket) // 验证并消耗 ticket
if (!userId) {
throw new Response('Invalid or expired ticket', { status: 403 })
}
request.context.userId = userId
},
open(peer) {
peer.subscribe(`user:${peer.context.userId}`)
peer.send({ type: 'connected', userId: peer.context.userId })
},
})Flutter 客户端(Flutter Web 场景,原生端请用方式一):
/// Flutter Web:获取 WebSocket Ticket 并连接
Future<WebSocketChannel> connectWithTicket(String jwt) async {
// 1. 通过 HTTP API 换取 Ticket
final response = await http.post(
Uri.parse('https://your-server.com/api/ws/ticket'),
headers: {'Authorization': 'Bearer $jwt'},
);
if (response.statusCode != 200) {
throw Exception('获取 WebSocket ticket 失败');
}
final ticket = jsonDecode(response.body)['ticket'] as String;
// 2. 用 Ticket 连接 WebSocket
final uri = Uri.parse('wss://your-server.com/api/_ws?ticket=$ticket');
return WebSocketChannel.connect(uri);
}Ticket 方案为什么安全?
- Ticket 是短期(30 秒)、一次性、随机的,即使被日志记录也无法重用
- 长期 JWT 只通过 HTTP 头传递(
Authorization: Bearer),不会出现在 URL 中 - Ticket 用完即删,无法被重放攻击
- 这是 OWASP WebSocket 安全指南 推荐的做法
方式三:连接后发认证消息
不在 URL 中传递任何凭证,连接建立后第一条消息认证:
export default defineWebSocketHandler({
upgrade(request) {
// 先放行连接,但不设置认证信息
request.context.authenticated = false
},
open(peer) {
// 10 秒内未认证则断开
setTimeout(() => {
if (!peer.context.authenticated) {
peer.close(4003, '认证超时')
}
}, 10000)
},
message(peer, message) {
// 未认证时,第一条消息必须是认证
if (!peer.context.authenticated) {
const data = message.json()
if (data.type !== 'auth') {
peer.close(4003, '请先认证')
return
}
try {
const payload = verifyToken(data.token)
peer.context.userId = payload.userId
peer.context.authenticated = true
peer.subscribe(`user:${payload.userId}`)
peer.send({ type: 'auth_ok' })
} catch {
peer.close(4003, '认证失败')
}
return
}
// 认证后处理业务消息
// ...
},
})Flutter 客户端:
final ws = WebSocketChannel.connect(Uri.parse('wss://your-server.com/api/_ws'));
// 连接后立即发送认证
ws.sink.add(jsonEncode({
'type': 'auth',
'token': jwt,
}));INFO
️ 方式二的局限:未认证连接会占用服务器资源 需要超时机制。攻击者可以大量建立连接但不认证(DoS 风险),需配合连接数限制
方式四:URL 参数直接传 JWT(仅限开发环境)
// 客户端
const token = 'your-jwt-token'
const ws = new WebSocket(`ws://localhost:3000/api/_ws?token=${token}`)// 服务端
export default defineWebSocketHandler({
upgrade(request) {
const url = new URL(request.url)
const token = url.searchParams.get('token')
if (!token) {
throw new Response('Missing token', { status: 401 })
}
try {
const payload = verifyToken(token)
request.context.userId = payload.userId
} catch {
throw new Response('Invalid token', { status: 403 })
}
},
open(peer) {
peer.subscribe(`user:${peer.context.userId}`)
peer.send({ type: 'connected', userId: peer.context.userId })
},
})INFO
️ 仅建议开发环境使用 JWT 是长期凭证,放在 URL 中会被日志记录、可能泄露。生产环境请使用方式一或方式二
四种方式对比
| 方式 | 安全性 | 复杂度 | 适用场景 |
|---|---|---|---|
| Authorization 头 | ⭐⭐⭐ 最高 | 最低 | 原生客户端首选(Flutter 原生、Node.js、Python 等) |
| 短期 Ticket | ⭐⭐⭐ 最高 | 中等 | 浏览器/Web 客户端首选 |
| 连接后认证 | ⭐⭐ 较高 | 较低 | 无法修改服务端时 |
| URL 传 JWT | ⭐ 较低 | 最低 | 开发/调试 |
Nginx 日志脱敏
如果必须用 URL 传 Token/Ticket,确保 Nginx 配置了日志脱敏:
# 在 log_format 中避免记录 query string
log_format ws_log '$remote_addr - $request_uri_without_query';或者使用 Ticket 方案——Ticket 本身就是短期的,即使泄露也无法重用。
生产环境额外安全措施
- 始终使用
wss://:生产环境必须加密传输,绝不用ws:// - Origin 校验:在
upgrade钩子中验证请求来源 - 会话过期处理:JWT 过期后应主动断开 WebSocket 连接
- 消息级权限控制:不要只验证连接,每条消息的操作也要检查权限
- 连接数限制:防止 DoS 攻击
- 登出即断开:用户登出时,关闭其所有 WebSocket 连接
// upgrade 中校验 Origin
upgrade(request) {
// 1. Origin 校验(防止跨站 WebSocket 劫持 CSWSH)
const origin = request.headers.get('origin')
const allowedOrigins = ['https://your-app.com', 'https://admin.your-app.com']
if (!origin || !allowedOrigins.includes(origin)) {
throw new Response('Forbidden origin', { status: 403 })
}
// 2. 认证逻辑...
}心跳保活
WebSocket 连接可能因为网络波动、代理超时等原因被静默断开。心跳机制确保连接"活着":
export default defineWebSocketHandler({
upgrade(request) {
request.context.lastPing = Date.now()
},
open(peer) {
// 每 30 秒发送一次心跳
peer.context.pingInterval = setInterval(() => {
if (Date.now() - peer.context.lastPing > 60000) {
// 60 秒没收到 pong,认为连接已死
peer.close(4000, '心跳超时')
return
}
peer.send({ type: 'ping' })
}, 30000)
},
message(peer, message) {
const data = message.json()
if (data.type === 'pong') {
peer.context.lastPing = Date.now()
return
}
// ...处理业务消息
},
close(peer) {
// 清理定时器
if (peer.context.pingInterval) {
clearInterval(peer.context.pingInterval)
}
},
})客户端对应逻辑:
const ws = new WebSocket('ws://localhost:3000/api/_ws')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'ping') {
// 收到心跳,立即回复
ws.send(JSON.stringify({ type: 'pong' }))
return
}
// ...处理业务消息
}心跳参数参考
- 发送间隔:30 秒(不能太频繁,浪费带宽;不能太长,检测不到断线)
- 超时断开:60 秒(2 倍心跳间隔,容忍偶尔丢包)
- 这些值需要根据实际网络环境调整
连接管理
统计在线连接
// server/utils/ws-connections.ts
const connections = new Map<string, any>() // peerId -> peer
export const addConnection = (peer: any) => {
connections.set(peer.id, peer)
}
export const removeConnection = (peer: any) => {
connections.delete(peer.id)
}
export const getOnlineCount = () => connections.size
export const getUserPeer = (userId: number) => {
for (const [, peer] of connections) {
if (peer.context.userId === userId) return peer
}
return null
}在 WebSocket Handler 中使用
export default defineWebSocketHandler({
open(peer) {
addConnection(peer)
console.log(`在线连接数:${getOnlineCount()}`)
},
close(peer) {
removeConnection(peer)
console.log(`在线连接数:${getOnlineCount()}`)
},
})INFO
️ connections Map 在多实例部署时的问题:每个 Node.js 进程有自己的 Map 如果部署了多个实例,Map 不会共享。解决方案
- 单实例部署:Map 够用
- 多实例部署:用 Redis PubSub 跨实例通信(参见实战项目章节)
与 API 路由配合
WebSocket 和 HTTP API 可以协同工作——HTTP API 接收外部请求,通过 WebSocket 推送给客户端:
// server/api/_ws.ts — WebSocket 端点
export default defineWebSocketHandler({
upgrade(request) {
const url = new URL(request.url)
const userId = url.searchParams.get('userId')
request.context.userId = userId
},
open(peer) {
if (peer.context.userId) {
peer.subscribe(`user:${peer.context.userId}`)
}
},
})// server/api/notify.post.ts — HTTP API
export default defineEventHandler(async (event) => {
const { userId, title, content } = await readBody(event)
// 通过 WebSocket 发布通知(如果有对应订阅者)
// 注意:publish 是 peer 的方法,这里需要用 Nitro 的广播方式
// 参见实战项目中的具体实现
return { success: true, message: '通知已发送' }
})核心模式
HTTP API 负责接收外部请求 → 内部找到对应的 WebSocket 连接 → 推送消息。这是"消息推送"应用的标准架构。
SSE(Server-Sent Events)替代方案
如果只需要服务端向客户端单向推送(不需要客户端主动发消息),SSE 比 WebSocket 更简单:
- SSE 基于标准 HTTP,无需 WebSocket 升级
- 自动重连、更简单的部署
- 不支持双向通信
适合场景:实时日志流、股票行情、通知推送等只需服务端单向推送的场景。
常见问题
WebSocket 连接后马上断开?
最常见的原因是未启用 WebSocket 支持。必须在 nuxt.config.ts 中设置 nitro.experimental.websocket: true,否则 defineWebSocketHandler 不会生效,WebSocket 升级请求不会被正确处理。
WebSocket 连接报 404?
检查文件路径是否正确。server/api/_ws.ts 对应的连接地址是 ws://localhost:3000/api/_ws,注意前缀 /api/。
开发环境连接被拒绝?
Vite 的 HMR 也会使用 WebSocket,开发环境的 WebSocket 端口可能需要特殊处理。确保 Nuxt 开发服务器正在运行。
生产环境 Nginx 代理 WebSocket?
Nginx 默认不支持 WebSocket 代理,需要显式配置:
location /api/_ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # WebSocket 长连接需要较长的超时
}INFO
️ proxy_read_timeout 86400:Nginx 默认 60 秒无数据就断开连接 WebSocket 是长连接,必须设置更长的超时(86400 = 24 小时),或者依赖心跳保活
多实例部署 WebSocket 消息丢失?
每个 Node.js 实例只管理自己的 WebSocket 连接。如果用户 A 连在实例 1,用户 B 连在实例 2,实例 1 的 publish 推不到用户 B。解决方案:用 Redis PubSub 在实例间转发消息(详见实战项目)。
知识脉络
API 路由 → 服务路由 → 服务中间件 → 服务插件 → 工具函数 → 事件处理 → 预渲染
│
你在这里:WebSocket
│
┌─────┴─────┐
│ │
实战项目: 插件模式:
消息推送 .client.ts