Hocuspocus 服务器示例

运行 Hocuspocus

命令行界面(CLI)

有时候,你只想非常快速地启动一个本地 Hocuspocus 实例。也许只是想试用一下,或者本地测试你的 webhook。我们的 CLI 能在几秒内将 Hocuspocus 带到你的命令行。

大多数情况下你只需用 npx 命令启动,当然你也可以通过 npm 或 yarn 全局安装或安装到你的项目中。

npx @hocuspocus/cli
npx @hocuspocus/cli --port 8080
npx @hocuspocus/cli --webhook http://localhost/webhooks/hocuspocus
npx @hocuspocus/cli --sqlite
npx @hocuspocus/cli --s3 --s3-bucket my-documents

Express

当你没有在 Hocuspocus 上调用 listen() 时,它本身不会启动 WebSocket 服务器——你需要负责调用它的 handleConnection() 方法,并将消息/关闭事件转发给返回的 ClientConnection

自 v4 起,Hocuspocus 基于 crossws 构建,因此同样的集成模式可用于 Express、Koa、原生 node:http 以及其他 Node 框架——在服务器的 upgrade 事件上挂载 crossws 的 Node 适配器,并将其连接到 Hocuspocus:

import { Logger } from '@hocuspocus/extension-logger'
import { Hocuspocus, type WebSocketLike } from '@hocuspocus/server'
import { createServer } from 'node:http'
import crossws from 'crossws/adapters/node'
import express from 'express'

const hocuspocus = new Hocuspocus({
  extensions: [new Logger()],
})

const app = express()

app.get('/', (request, response) => {
  response.send('你好,世界!')
})

const server = createServer(app)

const ws = crossws({
  hooks: {
    open(peer) {
      const clientConnection = hocuspocus.handleConnection(
        peer.websocket as unknown as WebSocketLike,
        peer.request as Request,
        { user_id: 1234 },
      )
      ;(peer as any)._hocuspocus = clientConnection
    },
    message(peer, message) {
      ;(peer as any)._hocuspocus?.handleMessage(message.uint8Array())
    },
    close(peer, event) {
      ;(peer as any)._hocuspocus?.handleClose({
        code: event.code,
        reason: event.reason,
      })
    },
    error(peer, error) {
      console.error('peer 的 WebSocket 错误:', peer.id)
      console.error(error)
    },
  },
})

server.on('upgrade', (request, socket, head) => {
  ws.handleUpgrade(request, socket, head)
})

server.listen(1234, () => console.log('正在 http://127.0.0.1:1234 上监听'))

重要提示!一些扩展会使用 onRequestonUpgradeonListen 钩子,这些钩子在此场景下不会被触发。

Hono

Hono 是一个支持多运行时的现代 Web 框架。它支持原生 WebSocket 协议,非常适合用于 Hocuspocus。只有在 Node.js 环境下,Hono 的实现才需要额外代码来支持 WebSocket 协议。

import { Hono } from 'hono'
import { Hocuspocus } from '@hocuspocus/server'

// Node.js 专用
import { serve } from "@hono/node-server";
import { createNodeWebSocket } from "@hono/node-ws";

const hocuspocus = new Hocuspocus({
  // …
})

const app = new Hono()

const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })

app.get(
  '/',
  upgradeWebSocket((c) => {
    let clientConnection
    return {
      onOpen(_evt, ws) {
        ws.raw.binaryType = 'arraybuffer'
        clientConnection = hocuspocus.handleConnection(ws.raw, c.req.raw, {})
      },
      onMessage(evt) {
        clientConnection?.handleMessage(new Uint8Array(evt.data))
      },
      onClose() {
        clientConnection?.handleClose()
      },
    }
  }),
)

const server = serve(
  {
    fetch: app.fetch,
    port: 8000,
  },
  (info) => {
    hocuspocus.hooks('onListen', {
      instance: hocuspocus,
      configuration: hocuspocus.configuration,
      port: info.port,
    })
  },
)

injectWebSocket(server)

重要提示!一些扩展会使用 onUpgradeonRequest 钩子,这些钩子在此场景下不会被触发。

Bun

自 v4 起,Hocuspocus 在底层使用 crossws,因此它也能在 Node.js 之外运行。在 Bun 上,直接实例化 Hocuspocus,并将 crossws 连接到 Bun.serve

import { Logger } from "@hocuspocus/extension-logger";
import { Hocuspocus } from "@hocuspocus/server";
import crossws from "crossws/adapters/bun";

const hocuspocus = new Hocuspocus({
	extensions: [new Logger()],
});

const ws = crossws({
	hooks: {
		open(peer) {
			// 使用 peer 方法而不是 peer.websocket,以避免
			// Bun 对 ServerWebSocket 的 Proxy `this` 绑定问题
			const wsLike = {
				get readyState() {
					return peer.websocket.readyState ?? 3; // 3 = 已关闭
				},
				send(data: any) {
					peer.send(data);
				},
				close(code?: number, reason?: string) {
					peer.close(code, reason);
				},
			};
			const clientConnection = hocuspocus.handleConnection(
				wsLike,
				peer.request as Request,
			);
			(peer as any)._hocuspocus = clientConnection;
		},
		message(peer, message) {
			(peer as any)._hocuspocus?.handleMessage(message.uint8Array());
		},
		close(peer, event) {
			(peer as any)._hocuspocus?.handleClose({
				code: event.code,
				reason: event.reason,
			});
		},
		error(peer, error) {
			console.error("peer 的 WebSocket 错误:", peer.id);
			console.error(error);
		},
	},
});

Bun.serve({
	port: 8000,
	websocket: ws.websocket,
	fetch(request, server) {
		if (request.headers.get("upgrade") === "websocket") {
			return ws.handleUpgrade(request, server);
		}

		return new Response("欢迎使用 Hocuspocus!");
	},
});

Deno

Deno 提供了 Deno.upgradeWebSocket() 和标准的 WebSocket 事件,这些都可以很自然地映射到 handleConnection()

import { Hocuspocus } from "@hocuspocus/server"

const hocuspocus = new Hocuspocus({
  name: "collaboration",
})

Deno.serve((req) => {
  if (req.headers.get("upgrade") !== "websocket") {
    return new Response(null, { status: 501 })
  }

  const { socket, response } = Deno.upgradeWebSocket(req)
  socket.binaryType = "arraybuffer"

  const clientConnection = hocuspocus.handleConnection(socket, req)

  socket.addEventListener("message", (event) => {
    clientConnection.handleMessage(new Uint8Array(event.data))
  })

  socket.addEventListener("close", (event) => {
    clientConnection.handleClose({ code: event.code, reason: event.reason })
  })

  return response
})

具体的实现细节取决于你的运行时环境,但基本构件是相同的:将 WebSocket、Request 和可选上下文传递给 handleConnection(),然后把传入的消息和关闭事件分发给返回的 ClientConnection

Koa

与上面的 Express 示例类似,v4 的方案是在底层 HTTP 服务器上使用 crossws Node 适配器:

import { Logger } from '@hocuspocus/extension-logger'
import { Hocuspocus, type WebSocketLike } from '@hocuspocus/server'
import { createServer } from 'node:http'
import crossws from 'crossws/adapters/node'
import Koa from 'koa'

const hocuspocus = new Hocuspocus({
  extensions: [new Logger()],
})

const app = new Koa()

app.use(async (ctx) => {
  ctx.body = '你好,世界!'
})

const server = createServer(app.callback())

const ws = crossws({
  hooks: {
    open(peer) {
      const clientConnection = hocuspocus.handleConnection(
        peer.websocket as unknown as WebSocketLike,
        peer.request as Request,
        { user_id: 1234 },
      )
      ;(peer as any)._hocuspocus = clientConnection
    },
    message(peer, message) {
      ;(peer as any)._hocuspocus?.handleMessage(message.uint8Array())
    },
    close(peer, event) {
      ;(peer as any)._hocuspocus?.handleClose({
        code: event.code,
        reason: event.reason,
      })
    },
    error(peer, error) {
      console.error('peer 的 WebSocket 错误:', peer.id)
      console.error(error)
    },
  },
})

server.on('upgrade', (request, socket, head) => {
  ws.handleUpgrade(request, socket, head)
})

server.listen(1234)

重要提示!一些扩展会使用 onRequestonUpgradeonListen 钩子,这些钩子在此场景下不会被触发。

PHP / Laravel(草稿)

我们创建了一个 Laravel 包来简化 Laravel 与 Hocuspocus 的集成。

详情请查看:ueberdosis/hocuspocus-laravel

Hocuspocus 的主要存储必须是 Y.Doc 的 Uint8Array 二进制格式。目前没有兼容的 PHP 库可以读取 YJS 格式,因此我们有两个选项来访问数据:将数据保存为 Laravel 兼容格式(如 JSON),作为主要存储的补充,或者创建一个独立的 nodejs 服务器,通过 API 读取主存储,解析 YJS 格式并返回给 Laravel。

注意:切勿尝试将 Y.Doc 存储为 JSON,并在用户连接时重新创建为 YJS 二进制。这会导致更新合并问题,内容在新连接时重复。数据必须以二进制格式存储,才能利用 YJS 格式的优势。

在主存储中保存数据

使用 Laravel 的迁移系统创建一个用于存储 YJS 二进制数据的表:

return new class extends Migration
{
    public function up()
    {
        Schema::create('documents', function (Blueprint $table) {
            $table->id();
            $table->binary('data');
        });
    }
    public function down()
    {
        Schema::dropIfExists('documents');
    }
};

在 Hocuspocus 服务器中,你可以使用 dotenv 库从 .env 文件获取数据库登录信息:

import mysql from 'mysql2';
import dotenv from 'dotenv';
dotenv.config()
const pool = mysql.createPool({
    connectionLimit: 100, //重要
    host: '127.0.0.1',
    user: process.env.DB_USERNAME,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_DATABASE,
    debug: false
});

然后使用数据库扩展结合 pool.query 来存储和读取二进制数据。

方案 1:另外以其他格式存储数据

使用webhook 扩展在文档更新时向 Laravel 发送请求,文档以 JSON 格式传递(详见这里)。

方案 2:通过独立的 nodejs 守护进程按需获取数据(高级)

使用 http 模块创建一个 nodejs 服务器:

const server = http.createServer(
...
).listen(3000, '127.0.0.1')

同样使用 dotenv 包获取 mysql 登录信息并执行请求。然后可使用 YJS 库解析二进制数据(Y.applyUpdate(doc, row.data))。你可以根据需要格式化数据,然后返回给 Laravel。

身份认证集成

你可以用 webhook 扩展实现身份认证——拒绝 onConnect 请求将导致 Hocuspocus 服务器断开连接——但对于安全性要求较高的应用,最好使用自定义 onAuthenticate 钩子,因为攻击者可能在被拒绝连接前从服务器获取一些数据。

要实现与 Laravel 服务器的认证,我们可以用 Laravel 的内置认证系统,借助 session cookie 和 CSRF 令牌。为你的 Hocuspocus 服务器脚本添加一个 onAuthenticate 钩子,将请求头(及 session cookie)传递出去,并将 CSRF 令牌添加到发送给 Laravel 服务器的请求中:

const hocusServer = new Server({
  ...
  onAuthenticate(data) {
        return new Promise((resolve, reject) => {
            // 在 v4 中,requestHeaders 是一个 web 标准的 Headers 对象
            const headers = Object.fromEntries(data.requestHeaders.entries());
            headers["X-CSRF-TOKEN"] = data.token;
            axios.get(
                process.env.APP_URL + '/api/hocus',
                { headers: headers },
            ).then(function (response) {
                if (response.status === 200)
                    resolve()
                else
                    reject()
            }).catch(function (error) {
                reject()
            })
        })
    },

并在 provider 中给请求添加 CSRF 令牌:

const provider = new HocuspocusProvider({
  ...
  token: '{{ csrf_token() }}',

最终,在 api.php 中添加路由响应该请求。响应可以为空,仅通过请求状态码验证认证状态(例如 200 或 403)。示例中使用了 Laravel 内置中间件来验证 session cookie 和 CSRF 令牌。你也可以按需添加其它中间件,比如 verified 或自定义中间件:

Route::middleware(['web', 'auth'])->get('/hocus', function (Request $request) {
    return response('');
});

就是这样!

本地编辑文档

如果你想直接在服务器上编辑文档(同时保持钩子和同步运行),最简单的方式是使用 Hocuspocus 的 getDirectConnection 方法。

const hocuspocus = new Hocuspocus()

const docConnection = await hocuspocus.openDirectConnection('my-document', {})

await docConnection.transact((doc) => {
  doc.getMap('test').set('a', 'b')
})

await docConnection.disconnect()