把 WebSocket 服务迁移到 Cloudflare Durable Objects —— 以一次协同编辑实战为例

Serverless 服务无状态、不支持长连接,但业务偏偏需要 WebSocket。为此专门开一台常驻服务器?Cloudflare Durable Objects 提供了一种按需唤醒的有状态服务,完美填补了这个空缺。本文先讲清通用模式,再以一个在线文档协同编辑的真实案例展示具体实现。


一、背景:Serverless 的长连接困境

越来越多的应用选择 Serverless 架构部署:阿里云 FC、AWS Lambda、Vercel、Cloudflare Pages 等。Serverless 天然适合"用完即走"的 HTTP 请求------没有流量时实例缩至零,有请求时瞬间拉起。无论是何种规模的项目,这种弹性都极具吸引力:

  • 独立开发者 / 个人展示案例:不为"没人访问时也得跑着"的服务器付费
  • SaaS 产品初期:用户量未起来前不需要预置服务器资源,按量付费轻装上阵
  • 企业内部工具:OA 审批、数据看板等使用频率低但不能下线的系统
  • IoT 与数据采集:设备偶尔上报数据,大部分时间空闲

但一旦业务需要引入 实时交互 能力------协同编辑、在线客服、消息推送、多人白板、游戏房间------问题就来了:

需求 Serverless 传统服务器
HTTP 请求 ✅ 完美适合 ✅ 当然可以
WebSocket 长连接 ❌ 无状态,不支持 ✅ 可以
空闲时成本 ✅ 零成本 ❌ 一直在跑
有状态的"房间" ❌ 实例随时可能被回收 ✅ 进程常驻

实时功能需要 WebSocket 长连接 + 有状态的房间管理,这两点恰恰是 Serverless 的硬伤。

传统做法是单独开一台常驻服务器跑 WebSocket 服务。但对于并发量不高的场景------初创产品的协同编辑、企业内部的实时通知、个人项目的多人互动------7×24 小时为可能只有几条连接的服务付月费,性价比太低

Cloudflare Durable Objects 为什么适合?

特性 说明
有状态 每个 DO 实例是一个独立的"房间",拥有自己的内存和存储
按需唤醒 空闲时自动休眠、有请求时自动恢复,不占资源
全球路由 同一个 ID 的请求无论从哪个边缘节点进来,都路由到同一个 DO 实例
原生 WebSocket 支持 WebSocket Hibernation API,连接保持但不占内存
零运维 无需管服务器、容器、进程守护

于是方案很清晰:主应用继续跑 Serverless(处理 HTTP),WebSocket 服务单独拆到 Cloudflare Durable Objects。两者各司其职。


二、整体架构

scss 复制代码
┌───────────────────────────────────────────────────────────────┐
│                       客户端 (Browser)                         │
│                                                               │
│  ┌──────────────┐          ┌────────────────────────────────┐ │
│  │  主应用页面   │          │  WebSocket 客户端              │  │
│  │  (HTTP 请求) │          │  (实时双向通信)                 │  │
│  └──────┬───────┘          └────────────┬───────────────────┘ │
└─────────┼───────────────────────────────┼─────────────────────┘
          │ HTTPS                         │ WSS
          ▼                               ▼
┌──────────────────┐           ┌──────────────────────────────┐
│  你的 Serverless  │          │  Cloudflare Workers           │
│  主应用           │          │                               │
│  ├ 页面 / API     │◄─ HTTP ─►│  Worker 入口 (index.ts)       │
│  ├ 数据库         │  回调     │    └ 根据 ID 路由到 DO        │
│  └ 内部 API       │          │                               │
│    (加载/保存)    │          │  Durable Object               │
│                   │          │    ├ 维护房间的内存状态        │
│                   │          │    ├ 管理多个 WebSocket 连接  │
│                   │          │    ├ 广播消息给房间内所有人    │
│                   │          │    └ 回调主应用持久化数据      │
└───────────────────┘          └──────────────────────────────┘

核心思路

  1. 一个 DO 实例 = 一个"房间":所有同一 ID(文档 ID、聊天室 ID、游戏房间 ID 等)的 WebSocket 连接都路由到同一个 DO。
  2. DO 内存中维护实时状态:这个状态可以是文档内容、聊天记录、游戏状态......取决于你的业务。
  3. 持久化仍在主应用:DO 通过 HTTP 回调主应用的 API 来加载/保存数据,DO 本身不做长期存储。

三、项目创建与部署

3.1 初始化项目

bash 复制代码
mkdir my-websocket-service && cd my-websocket-service
npm init -y
npm install wrangler typescript --save-dev
# 安装你业务需要的库,例如 yjs、socket 消息解析库等

项目结构:

perl 复制代码
my-websocket-service/
├── src/
│   ├── index.ts          # Worker 入口:路由请求到 DO
│   └── my-room-do.ts     # Durable Object:房间逻辑
├── wrangler.toml         # Cloudflare 配置
├── tsconfig.json
└── package.json

3.2 配置 wrangler.toml

toml 复制代码
name = "my-websocket-service"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]  # 如果用到 Node.js 库

placement = { mode = "smart" }  # 智能就近部署(详见第六节)

# Durable Objects 绑定
[durable_objects]
bindings = [
  { name = "MY_ROOM_DO", class_name = "MyRoomDO" }
]

# DO 迁移配置(首次部署必须)
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyRoomDO"]

# 敏感环境变量通过 Dashboard 或 wrangler secret 设置,不提交到代码库
[vars]
# MAIN_APP_URL = "https://your-app.com"
# API_SECRET = "your-secret"

注意[[migrations]] 是 Durable Objects 的必要配置,首次部署必须包含。

new_sqlite_classes vs new_classes 踩坑:Cloudflare DO 有两种存储后端:

  • new_classes(旧版)------ 使用 KV-style 存储 API(注意:这不是 Workers KV 产品,而是 DO 内置的键值存储接口),仅付费计划可用
  • new_sqlite_classes(新版)------ 每个 DO 内置 SQLite,Free 套餐可用,也是 Cloudflare 推荐的方向

如果你用的是 Free 套餐却写了 new_classes,部署时会报错 code: 10097。改成 new_sqlite_classes 即可。两者在 WebSocket + 内存状态管理的场景下行为完全一致,不影响业务逻辑。

3.3 部署方式一:Wrangler CLI

bash 复制代码
npx wrangler login       # 登录 Cloudflare(首次)
npx wrangler dev         # 本地开发 → localhost:8787
npx wrangler deploy      # 部署到线上

# 设置线上环境变量
npx wrangler secret put MAIN_APP_URL
npx wrangler secret put API_SECRET

3.4 部署方式二:关联 GitHub 自动部署(推荐)

每次 git push 自动触发构建部署,更适合正式项目。

第一步:Cloudflare 账号初始化

如果你是新注册的 Cloudflare 账号,初始引导页会让你选择起步方式:

  • 选择 "Start with code or a template"(Workers Compute)------ Durable Objects 是 Workers 平台的一部分
  • 另一个选项是 Pages(静态站点部署),不适合 DO 场景
  • 当然也可以直接点 Skip 跳过引导,进入 Dashboard 后手动创建

第二步:关联 GitHub 仓库

  1. 将代码推送到 GitHub 仓库
  2. 进入 Cloudflare Dashboard → Workers & PagesCreate
  3. 切换到 Workers 标签页 → 选择 Import a Repository
  4. 授权 GitHub 账号,选中你的 WebSocket 服务仓库(注意不要选错成主项目仓库)
  5. 构建配置:
    • Build commandnpm install(或留空,Cloudflare 会自动安装依赖)
    • Deploy commandnpx wrangler deploy(通常已自动填好)
  6. 点击 Deploy

第三步:配置环境变量

部署成功后,进入 Worker → SettingsVariables and Secrets ,添加业务所需的环境变量(如 MAIN_APP_URLAPI_SECRET 等)。这些变量不会出现在代码库中。

此后每次向 main 分支推送代码,Cloudflare 都会自动拉取、构建、部署,整个 CI/CD 流程通常在 30 秒内完成。

⚠️ 踩坑提醒 :使用 GitHub 自动部署时,在控制面板手动修改的配置(如 Smart Placement 的 Runtime 选项)会在下次构建时被 wrangler.toml 覆盖回默认值。所有配置都应落到代码中,让代码成为唯一的配置来源。

⚠️ wrangler deploy 会清除 Dashboard 手动设置的明文变量 :如果你只在 Cloudflare Dashboard 的"Variables and Secrets"页面填写了明文变量(如 MAIN_APP_URL),执行 wrangler deploy 后这些变量会被完全清空 ------Cloudflare 以本地 wrangler.toml 为准,[vars] 中没有的就视为"已删除"。

类型 设置方式 重部署后是否保留
Variables(明文) 仅 Dashboard 手动填写 会被清除
Variables(明文) 写入 wrangler.toml [vars] ✅ 随部署生效
Secrets(加密) npx wrangler secret put KEY 永远不被覆盖

正确做法:明文配置写 wrangler.toml,密钥用 wrangler secret put


四、核心实现:DO + WebSocket 通用模式

本节展示的是一个 通用的 DO + WebSocket 骨架,适用于任何需要"房间"概念的实时应用。第八节会以协同编辑为例展示如何填充具体业务逻辑。

4.1 Worker 入口:将请求路由到正确的 DO

typescript 复制代码
// src/index.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // 健康检查
    if (url.pathname === '/' && !request.headers.get('Upgrade')) {
      return new Response(JSON.stringify({ status: 'ok' }));
    }

    // 从 URL 中提取房间 ID(你的业务决定如何传递)
    const roomId = url.pathname.split('/').filter(Boolean).pop();
    if (!roomId) {
      return new Response('Missing room ID', { status: 400 });
    }

    // 核心:idFromName 保证相同 roomId → 同一个 DO 实例
    const durableId = env.MY_ROOM_DO.idFromName(roomId);
    const stub = env.MY_ROOM_DO.get(durableId, { locationHint: 'apac' });

    return stub.fetch(request);
  },
};

idFromName(roomId) 是 Durable Objects 的关键 API:它将业务标识映射为全局唯一的 DO 实例 。无论请求从全球哪个边缘节点进来,同一个 roomId 始终路由到同一个 DO。

4.2 Durable Object 骨架:WebSocket 房间管理

typescript 复制代码
// src/my-room-do.ts
export class MyRoomDO implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  // ① 处理新连接
  async fetch(request: Request): Promise<Response> {
    if (request.headers.get('Upgrade') !== 'websocket') {
      return new Response('Expected WebSocket', { status: 426 });
    }

    // 在这里可以做鉴权、加载初始数据等

    // 创建 WebSocket 对
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // 关键:使用 Hibernation API 接受连接
    // 这让 DO 在空闲时可以释放内存,但 WebSocket 不断开
    this.state.acceptWebSocket(server);

    // 可选:给新连接发送初始状态
    // server.send(JSON.stringify({ type: 'init', data: this.currentState }));

    return new Response(null, { status: 101, webSocket: client });
  }

  // ② 处理客户端发来的消息
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    // 根据你的业务协议解析消息
    // 然后广播给房间内其他人
    this.broadcast(message, ws);
  }

  // ③ 处理连接断开
  async webSocketClose(ws: WebSocket) {
    const remaining = this.state.getWebSockets();
    if (remaining.length === 0) {
      // 房间空了,执行清理/保存操作
    }
  }

  // ④ 广播:发给房间内除发送者外的所有人
  private broadcast(message: string | ArrayBuffer, exclude?: WebSocket) {
    for (const ws of this.state.getWebSockets()) {
      if (ws !== exclude) {
        try {
          ws.send(message);
        } catch {}
      }
    }
  }
}

这就是 DO + WebSocket 的完整骨架。任何实时应用 ------聊天室、游戏房间、协同编辑、实时看板------都是在这个骨架上填充 webSocketMessage 里的业务逻辑。

4.3 与主应用的数据桥梁

DO 本身不直接访问你的数据库。推荐通过 HTTP 回调主应用的内部 API:

typescript 复制代码
// 主应用:/api/internal/route.ts

// 验证请求来源(共享密钥)
function verifySecret(request: Request): boolean {
  const token = request.headers.get('Authorization')?.replace('Bearer ', '');
  return token === process.env.API_SECRET;
}

// GET → DO 启动时加载数据
export async function GET(request: Request) {
  if (!verifySecret(request)) return Response.json({ error: 'Unauthorized' }, { status: 401 });
  const id = new URL(request.url).searchParams.get('id');
  const data = await db.findById(id);
  return Response.json(data);
}

// POST → DO 将状态保存回数据库
export async function POST(request: Request) {
  if (!verifySecret(request)) return Response.json({ error: 'Unauthorized' }, { status: 401 });
  const body = await request.json();
  await db.update(body.id, body.data);
  return Response.json({ success: true });
}

安全性通过共享密钥 API_SECRET 保证,不经过用户 session 认证(这是服务间调用)。


五、Hibernation API:省钱的关键

Durable Objects 按 内存驻留时间 计费。如果 DO 实例一直占着内存等待消息,成本会很高。

Cloudflare 提供了 WebSocket Hibernation API

typescript 复制代码
// ❌ 传统方式:DO 一直在内存中等待
ws.addEventListener('message', handler);

// ✅ Hibernation API:DO 可以在空闲时释放内存
this.state.acceptWebSocket(server);

使用 acceptWebSocket 后,当没有消息时,DO 可以释放内存进入休眠。新消息到达时自动唤醒,调用 webSocketMessage 回调。WebSocket 连接不会断开,但 DO 不占内存

大部分时间没有消息传输时,DO 处于休眠状态,几乎零成本。无论是个人项目还是初创产品的低频实时功能,都不必为空闲时间买单。


六、部署优化:Smart Placement + locationHint

问题

主应用部署在某个固定区域(例如阿里云新加坡),而 Cloudflare Workers 默认在离用户最近的边缘节点执行。如果 DO 实例分配在美国,那 DO 每次回调主应用的 HTTP 请求就要跨越太平洋------导致 连接超时首次加载缓慢

解决方案:双管齐下

1. Smart Placement(智能就近部署)

toml 复制代码
# wrangler.toml
placement = { mode = "smart" }

Cloudflare 会分析 Worker 的网络请求模式,自动将 DO 实例部署到离你后端服务最近的数据中心。

2. locationHint(区域倾向提示)

typescript 复制代码
const stub = env.MY_ROOM_DO.get(durableId, { locationHint: 'apac' });

apac 提示 Cloudflare 优先在亚太区域创建 DO 实例。可选值包括 wnam(北美西部)、enam(北美东部)、weur(西欧)、eeur(东欧)、apac(亚太)等。

⚠️ 再次提醒:使用 GitHub 自动部署时,placement 必须写在 wrangler.toml 里,否则每次构建会被重置为 Default。


七、本地开发环境

本地开发时,两个服务形成独立闭环,互不影响线上:

组件 配置 来源
主应用前端 WS_URL=ws://localhost:8787 .env
Wrangler Worker MAIN_APP_URL=http://localhost:3000 .dev.vars
bash 复制代码
# 终端 1:启动主应用
cd my-app && npm run dev           # → localhost:3000

# 终端 2:启动 WebSocket 服务
cd my-websocket-service && npx wrangler dev  # → localhost:8787

线上环境通过各平台的环境变量覆盖,本地 .env.dev.vars 不影响生产。

💡 简化方案 :如果你不想本地跑 wrangler dev,也可以让前端直接连线上的 Cloudflare Worker(wss://xxx.workers.dev)。这时只需确保 DO 环境变量中的 MAIN_APP_URL 指向线上地址即可。这种方式省去了本地启动 WebSocket 服务的步骤,适合前端开发调试。


八、实战案例:协同编辑的业务逻辑

本节是协同编辑场景的具体实现。如果你的业务是聊天室、游戏或其他场景,可以跳过本节,只需替换第四节骨架中的消息处理逻辑即可。

我的项目 入木 AI 是一个在线文档编辑器,主应用基于 Next.js 部署在阿里云 Serverless FC(新加坡),需要为其加上多人实时协同编辑能力。

为什么不把 Next.js 整体迁到 Cloudflare?

Cloudflare 有 next-on-pages 等方案可以跑 Next.js,但它运行在 Edge Runtime 上,有些限制:

  • 不能用 Node.js 原生模块(fsnetcrypto 等)
  • Prisma ORM 需要改用 edge-compatible driver
  • next/image 等中间件行为不一致
  • NextAuth、邮件服务等第三方库需要逐一适配

迁移成本远大于收益。所以我选择了最小侵入方案:只把 WebSocket 服务拆出来部署到 Cloudflare Durable Objects,主应用保持不动------这也正是本文前四节介绍的架构模式。

技术选型

组件 技术 作用
CRDT 引擎 Yjs 无冲突的文档状态合并
富文本编辑器 Tiptap (ProseMirror) 编辑器 UI + Yjs 绑定
WebSocket 客户端 @hocuspocus/provider 管理与 DO 的连接、Yjs 同步
WebSocket 服务端 Cloudflare DO 文档房间管理

8.1 Yjs 同步协议

在第四节的骨架中,webSocketMessage 里需要处理两种消息:

消息类型 作用
Sync 消息 (type=0) 文档内容同步:新用户加入时的全量同步 + 之后的增量更新
Awareness 消息 (type=1) 用户状态广播:光标位置、在线头像、编辑状态

Yjs 的三步握手(新用户加入时):

vbnet 复制代码
  新用户 A                     Durable Object
     │                              │
     │◄──── Sync Step 1 ────────────│  (DO 发送自己的 stateVector)
     │                              │
     │───── Sync Step 2 ───────────►│  (A 根据 stateVector 计算差异发回)
     │                              │
     │◄──── Sync Update ────────────│  (DO 把其他用户的更新推给 A)
     │                              │
     ▼ 此后双向实时增量同步 ▼

8.2 DO 中的业务逻辑填充

在第四节的通用骨架基础上,协同编辑的 DO 需要:

typescript 复制代码
export class CollaborateDO implements DurableObject {
  private doc: Y.Doc; // Yjs 文档对象,维护在内存中
  private docLoaded = false;

  constructor(state: DurableObjectState, env: Env) {
    this.doc = new Y.Doc();

    // 监听文档变更 → 广播增量更新 + debounce 保存
    this.doc.on('update', (update, origin) => {
      this.broadcast(encodeSyncUpdate(update), origin as WebSocket);
      this.debounceSave(); // 5秒内无新编辑则保存到数据库
    });
  }

  async fetch(request: Request) {
    // 首次连接时从主应用加载文档
    if (!this.docLoaded) await this.loadDocument();

    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.state.acceptWebSocket(server);

    // 给新客户端发送 Sync Step 1,启动三步握手
    server.send(encodeSyncStep1(this.doc));

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: ArrayBuffer) {
    const { messageType, decoder } = decodeMessage(message);

    switch (messageType) {
      case MESSAGE_SYNC: // 文档同步
        const reply = handleSyncMessage(decoder, this.doc, ws);
        if (reply) ws.send(reply);
        break;
      case MESSAGE_AWARENESS: // 光标/在线状态
        const data = readVarUint8Array(decoder);
        this.broadcast(encodeAwarenessUpdate(data), ws);
        break;
    }
  }

  async webSocketClose() {
    if (this.state.getWebSockets().length === 0) {
      await this.saveDocument(); // 最后一人离开 → 保存
    }
  }
}

8.3 前端接入

前端只需几行代码即可接入:

typescript 复制代码
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';

const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
  url: `wss://my-websocket-service.workers.dev/${docId}`,
  name: docId,
  document: ydoc,
  connect: false, // 不自动连接,由 useEffect 手动管理
});

// 在合适的时机手动连接(如 React useEffect 中)
provider.connect();

// 组件卸载时断开连接
// provider.disconnect();
// provider.destroy();

Tiptap 编辑器通过两个扩展完成协同绑定:

  • Collaboration --- 将编辑器绑定到 Y.Doc,编辑操作自动转化为 Yjs 更新
  • CollaborationCursor --- 通过 Awareness 协议同步各用户的光标位置和信息

⚠️ Yjs 重复导入警告 :如果你在控制台看到 Yjs was already imported. This breaks constructor checks...,说明打包工具把 yjs 打包了多份(常见于 monorepo 或依赖树中有多个 yjs 版本)。解决方法是在 bundler 配置中添加 resolve.alias,确保所有模块引用同一份 yjs。


九、DO vs Node.js 服务端:设计取舍

选择 Cloudflare DO 意味着放弃了标准 Node.js 环境。如果你的 WebSocket 服务使用 @hocuspocus/server 部署在 VPS 上,以下问题根本不会出现。但在 DO 的 Edge Runtime 中,有几个地方需要换一种思路来解决。

9.1 历史数据迁移:JSON → Yjs 转换

Node.js 方案 (标准 @hocuspocus/server):

服务端在 Database.fetch Hook 中检查:如果数据库里只有 JSON 格式的文档内容(contentBinary 为空),就用 TiptapTransformer.toYdoc() 把 JSON 转成 Yjs Binary 再返回给客户端。这个过程对前端完全透明。

typescript 复制代码
// Node.js 环境可以这样做
import { TiptapTransformer } from '@hocuspocus/transformer'

async fetch({ documentName }) {
  const doc = await db.findById(documentName)
  if (doc.contentBinary) {
    return doc.contentBinary       // 已有 binary → 直接返回
  }
  // 历史文档只有 JSON → 服务端转换为 Yjs binary
  return TiptapTransformer.toYdoc(JSON.parse(doc.content), extensions)
}

DO 的问题TiptapTransformer 底层依赖 ProseMirror 的 DOM 解析(DOMParser),Workers Runtime 没有 DOM 环境,无法运行

DO 的解决方案------前端兜底

DO 启动时从主应用拉取数据。如果 contentBinary 不为空(Base64),用 Y.applyUpdate 加载到内存的 Y.Doc 中;如果为空,DO 不做任何事------Y.Doc 保持空白。

前端这边,page.tsx(服务端组件)会把文档的 JSON 内容通过 props 传到客户端。编辑器初始化后,监听 Provider 的 synced 事件,发现 Y.Doc 为空时将 JSON 内容注入编辑器:

typescript 复制代码
// 前端 editor 组件
const handleSynced = () => {
  const yXmlFragment = provider.document.getXmlFragment('default');
  if (yXmlFragment.length === 0 && window.__initialContent) {
    const json = JSON.parse(window.__initialContent);
    delete window.__initialContent; // 用完即删,避免泄露给后续新文档
    editor.commands.setContent(json, { emitUpdate: true });
    // emitUpdate: true → Yjs 感知到变更 → 自动同步到 DO → DO 保存 binary
    // 下次打开时 contentBinary 已有值,不再走这条路径
  }
};
provider.on('synced', handleSynced);

⚠️ 注意 emitUpdate 必须为 true :如果设为 false,Yjs 不会感知到这次内容变更,服务端也不会保存 binary。下次打开还是空文档,陷入死循环。设为 true 后,这次注入会立刻通过 Yjs 同步到 DO 并触发 debounceSave,将 binary 保存到数据库。一次转换,永久生效

这种方案的代价是:历史文档第一次在协同模式下打开时,会有极短的空白闪烁(等待 synced → inject 完成)。但只要打开一次,后续加载都走 binary,体验与新文档完全一致。

9.2 全文搜索与预览:Yjs Binary → JSON 反向转换

Node.js 方案@hocuspocus/serveronStoreDocument Hook 中可以同时做两件事:将 Yjs Binary 保存到 contentBinary,同时用 TiptapTransformer.fromYdoc() 将内容还原为 JSON 字符串写入 content 字段。这样全文搜索、文档预览等功能可以直接查询 JSON 字段。

DO 的问题 :同样因为缺少 DOM 环境,TiptapTransformer.fromYdoc() 无法在 Workers 中运行。DO 只能保存 Binary,无法反向生成 JSON。

DO 的解决方案

如果你需要全文搜索功能,有几个替代方案:

  1. 主应用内部 API 做转换 :DO 保存 binary 时,主应用的 POST 接口收到 binary 后,在 Node.js 环境中做 fromYdoc() 转换并更新 content 字段。这需要主应用安装 @hocuspocus/transformer 和编辑器相关扩展。

  2. 完全不维护 JSON 字段 :如果项目初期不需要全文搜索,可以简化架构,后期需要时再补充。毕竟 contentBinary 里完整保留了文档内容,数据不会丢失。

  3. 利用搜索引擎:如果已接入 Elasticsearch 或 Algolia 等搜索服务,可以在前端或主应用层面将编辑器内容推送到搜索索引,绕过 JSON 字段的需求。

9.3 监控与报警

Node.js 方案 :通常会部署一个独立的 monitor 进程,定时(如每 15 分钟)ping WebSocket 服务的 HTTP 接口,失败时发邮件报警。服务端也会在 onError Hook 中触发邮件通知。

DO 不需要这样做

Cloudflare 平台自带完善的可观测性工具:

工具 用途
Workers Analytics 请求量、错误率、P50/P99 延迟,按时间段聚合
Real-time Logs wrangler tail 实时查看线上日志(console.log 输出)
Durable Objects Metrics DO 实例数、WebSocket 连接数、存储容量
Notifications 可配置告警规则(错误率超阈值 → 邮件 / Webhook / PagerDuty)
bash 复制代码
# 实时查看线上日志(排查线上问题的利器)
npx wrangler tail

自建心跳监控在 DO 场景下反而有副作用:定时 ping 会唤醒处于 Hibernation 的 DO 实例,产生不必要的费用。Cloudflare 的监控是在平台层面运行的,不会唤醒你的 DO。

9.4 对比总结

问题 Node.js @hocuspocus/server Cloudflare DO
JSON → Yjs 转换 服务端 TiptapTransformer.toYdoc() 前端 synced 事件兜底注入
Yjs → JSON 反向转 服务端 onStoreDocument Hook 主应用内部 API 补充转换(可选)
监控报警 自建 monitor + 邮件 Cloudflare 平台原生 Analytics
优势 完整 Node.js 生态,库随便用 零运维,按需付费,全球低延迟
代价 需要常驻服务器 + 运维 Edge Runtime 限制,部分库不可用

十、总结

对比维度 迁移前(常驻服务器) 迁移后(Durable Objects)
WebSocket 服务 需要常驻服务器 按需唤醒
空闲成本 服务器月费 几乎为零(Hibernation)
弹性扩容 手动 自动(每个房间独立 DO)
全球延迟 取决于服务器位置 边缘网络 + Smart Placement
主应用改动 --- 仅新增一个内部 API

最终的架构很简单:Serverless 做它擅长的事(HTTP 请求),Durable Objects 做它擅长的事(有状态长连接)。两者通过一个 HTTP 内部 API 桥接,各司其职。

无论是个人项目、初创产品还是企业内部工具,只要你的应用"大部分时间在处理 HTTP 请求,偶尔需要实时长连接",这套 Serverless + Durable Objects 的组合都值得一试------成本最低、运维最省、弹性最好

相关推荐
Highcharts.js2 小时前
Highcharts跨域数据加载完全指南:JSONP原理与实战
javascript·数据库·开发文档·highcharts·图表开发·跨域数据
skywalk81633 小时前
electrobun 使用TypeScript构建超快速、小巧且跨平台的桌面应用程序(待续)
前端·javascript·typescript
薛一半5 小时前
React的数据绑定
前端·javascript·react.js
爱看书的小沐5 小时前
【小沐杂货铺】基于Three.js渲染三维无人机Drone(WebGL / vue / react )
javascript·vue.js·react.js·无人机·webgl·three.js·drone
ShenJLLL11 小时前
vue部分知识点.
前端·javascript·vue.js·前端框架
belldeep15 小时前
nodejs:如何使用 express markdown-it 实现指定目录下 Md 文件的渲染
node.js·express·markdown
Never_Satisfied19 小时前
在JavaScript / HTML中,数组查找第一个符合要求元素
开发语言·javascript·html
HelloReader20 小时前
Tauri 2 创建项目全流程create-tauri-app 一键脚手架 + Tauri CLI 手动接入
前端·javascript·vue.js
shix .21 小时前
旅行网站控制台检测
开发语言·前端·javascript