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-documentsExpress
当你没有在 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 上监听'))重要提示!一些扩展会使用 onRequest、onUpgrade 和 onListen 钩子,这些钩子在此场景下不会被触发。
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)重要提示!一些扩展会使用 onUpgrade 和 onRequest 钩子,这些钩子在此场景下不会被触发。
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)重要提示!一些扩展会使用 onRequest、onUpgrade 和 onListen 钩子,这些钩子在此场景下不会被触发。
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()