Skip to content

WebSocket

WebSocket 是一种在客户端和服务端之间建立持久双向连接的通信协议。HTTP 是"一问一答",WebSocket 是"随时互发"——服务端可以主动推数据给客户端,不需要客户端先请求。

为什么需要 WebSocket?

场景HTTPWebSocket
聊天消息客户端需要不断轮询"有新消息吗?"服务端有消息直接推过去
实时通知延迟高,可能错过即时送达
协同编辑需要频繁请求同步双方实时同步
状态监控每隔 N 秒请求一次数据变了立即推送

一句话理解

HTTP 像发邮件(你问我才答),WebSocket 像打电话(双方随时说话)。

Nuxt 中的 WebSocket

Nuxt 基于 Nitro 引擎,Nitro 提供了跨平台 WebSocket 支持(基于 CrossWS)。你需要在配置中启用 WebSocket,然后在 server/ 目录下创建路由文件,使用 defineWebSocketHandler 即可。

启用 WebSocket

nuxt.config.ts 中启用 WebSocket 支持:

ts
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    experimental: {
      websocket: true,  // Nitro v2 使用 experimental
    },
  },
})

Nitro v3 变化

Nitro v3 将 websocketexperimental 移到了 features,配置变为 nitro.features.websocket: true。当前 Nuxt 4 使用的 Nitro v2 仍用 experimental

基本结构

ts
// 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/_ws
  • server/api/chat.ts → 连接地址为 ws://localhost:3000/api/chat
  • 文件名以 _ 开头只是惯例,表示"内部"端点,不是必须的

客户端连接

ts
// 浏览器端
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,不能通过返回值设置 contextnamespace。Nitro v3 基于 crossws 0.4.x,支持 return { context, namespace, headers }

ts
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 钩子不能通过返回值设置 contextnamespace。设置上下文数据请通过 request.context(见下方说明),命名空间由文件路径决定

Nitro v3 变化

Nitro v3(crossws 0.4.x)的 upgrade 钩子支持返回 { headers, namespace, context } 对象,可以直接通过返回值设置上下文和命名空间。

open

WebSocket 连接建立后触发,peer 可以正常收发消息。

message

收到客户端消息时触发。

close

连接关闭时触发,接收 details 对象,包含 codereason

error

连接发生错误时触发。

Peer 对象

peer 代表一个 WebSocket 连接(一个客户端),是最核心的对象:

属性

属性类型说明
peer.idstring连接唯一标识(UUID v4,自动生成)
peer.contextobject自定义上下文数据(通过 upgrade 钩子中的 request.context 设置)
peer.requestUpgradeRequest原始的 WebSocket 升级请求(部分运行时可能不可用)
peer.peersSet<Peer>所有已连接的 peer(不限于同一命名空间)
peer.topicsSet<string>该连接订阅的所有主题
peer.remoteAddressstring?客户端 IP 地址(部分运行时不可用)
peer.websocketPartial<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 读取:

ts
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 读取
}

消息格式

发送和接收

ts
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.idstring消息唯一标识(UUID v4)
message.peerPeer发送该消息的 peer 实例
message.rawDataany原始消息数据

Message 对象方法

方法返回类型说明
message.text()string消息的 UTF-8 字符串
message.json()T消息解析为 JSON
message.uint8Array()Uint8Array消息的字节数组
message.arrayBuffer()ArrayBuffer消息的 ArrayBuffer
message.blob()Blob消息的 Blob

约定 JSON 消息格式

实际项目中,建议统一使用 JSON 格式通信,约定 type 字段区分消息类型:

ts
// 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 最常用的模式是"发布/订阅"——把连接分组,给某个组广播消息:

基本用法

ts
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 发给特定人

ts
// 发给除自己外的所有订阅者
peer.publish('room:1', message)

// 同时也发给自己
peer.send(message)

// 发给某一个人
peer.send(message)  // 只发给这个 peer

publish 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 的路径决定,这对动态路由特别自然:

ts
// 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 名称中包含分组标识:

ts
// 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 中。

服务端

ts
// 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 原生客户端(推荐):

dart
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:ioWebSocket.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)。

text
1. 客户端 → HTTP POST /api/ws/ticket(带 Authorization: Bearer <jwt>)
2. 服务端验证 JWT,生成一次性 Ticket(短期、随机、可撤销)
3. 客户端 → WebSocket ws://host/api/_ws?ticket=<ticket>
4. 服务端验证 Ticket,标记已使用,建立连接

服务端实现

ts
// 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

ts
// 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 场景,原生端请用方式一):

dart
/// 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 中传递任何凭证,连接建立后第一条消息认证:

ts
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 客户端

dart
final ws = WebSocketChannel.connect(Uri.parse('wss://your-server.com/api/_ws'));

// 连接后立即发送认证
ws.sink.add(jsonEncode({
  'type': 'auth',
  'token': jwt,
}));

INFO

方式二的局限:未认证连接会占用服务器资源 需要超时机制。攻击者可以大量建立连接但不认证(DoS 风险),需配合连接数限制

方式四:URL 参数直接传 JWT(仅限开发环境)

ts
// 客户端
const token = 'your-jwt-token'
const ws = new WebSocket(`ws://localhost:3000/api/_ws?token=${token}`)
ts
// 服务端
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 配置了日志脱敏:

nginx
# 在 log_format 中避免记录 query string
log_format ws_log '$remote_addr - $request_uri_without_query';

或者使用 Ticket 方案——Ticket 本身就是短期的,即使泄露也无法重用。

生产环境额外安全措施

遵循 OWASP WebSocket 安全指南

  1. 始终使用 wss://:生产环境必须加密传输,绝不用 ws://
  2. Origin 校验:在 upgrade 钩子中验证请求来源
  3. 会话过期处理:JWT 过期后应主动断开 WebSocket 连接
  4. 消息级权限控制:不要只验证连接,每条消息的操作也要检查权限
  5. 连接数限制:防止 DoS 攻击
  6. 登出即断开:用户登出时,关闭其所有 WebSocket 连接
ts
// 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 连接可能因为网络波动、代理超时等原因被静默断开。心跳机制确保连接"活着":

ts
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)
    }
  },
})

客户端对应逻辑:

ts
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 倍心跳间隔,容忍偶尔丢包)
  • 这些值需要根据实际网络环境调整

连接管理

统计在线连接

ts
// 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 中使用

ts
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 推送给客户端:

ts
// 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}`)
    }
  },
})
ts
// 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 代理,需要显式配置:

nginx
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 在实例间转发消息(详见实战项目)。

知识脉络

text
API 路由 → 服务路由 → 服务中间件 → 服务插件 → 工具函数 → 事件处理 → 预渲染

                                                            你在这里:WebSocket

                                                          ┌─────┴─────┐
                                                          │           │
                                                    实战项目:     插件模式:
                                                    消息推送       .client.ts

基于 Nuxt 4 官方文档整理编写