---
title: "Hocuspocus 服务器示例"
canonical_url: "https://tiptap.zhcndoc.com/hocuspocus/server/examples"
---

# Hocuspocus 服务器示例

## 运行 Hocuspocus

### 命令行界面（CLI）

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

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

```bash
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()` 方法](https://tiptap.zhcndoc.com/hocuspocus/server/usage.md)，并将消息/关闭事件转发给返回的 `ClientConnection`。

自 v4 起，Hocuspocus 基于 [crossws](https://github.com/unjs/crossws) 构建，因此同样的集成模式可用于 Express、Koa、原生 `node:http` 以及其他 Node 框架——在服务器的 `upgrade` 事件上挂载 `crossws` 的 Node 适配器，并将其连接到 Hocuspocus：

```ts
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 协议。

```ts
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](https://github.com/unjs/crossws)，因此它也能在 Node.js 之外运行。在 Bun 上，直接实例化 `Hocuspocus`，并将 `crossws` 连接到 `Bun.serve`：

```ts
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()`：

```ts
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 适配器：

```ts
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](https://github.com/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
});
```

然后使用[数据库扩展](https://tiptap.zhcndoc.com/hocuspocus/server/extensions/database.md)结合 `pool.query` 来存储和读取二进制数据。

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

使用[webhook 扩展](https://tiptap.zhcndoc.com/hocuspocus/server/extensions/webhook.md)在文档更新时向 Laravel 发送请求，文档以 JSON 格式传递（详见[这里](https://tiptap.dev/hocuspocus/guide/transformations#tiptap>))。

##### 方案 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` 方法。

```typescript
const hocuspocus = new Hocuspocus()

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

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

await docConnection.disconnect()
```

### 在断开连接后保持文档处于热状态

默认情况下，`disconnect()` 会立即持久化文档，并在其完成后将其从内存中卸载。如果你预计在不久后会再次为同一文档打开另一个直接连接，请传入 `unloadImmediately: false`。此时，存储会通过防抖定时器进行调度，文档会继续保留在内存中处于热状态，因此后续连接会复用它，重复写入也会合并——这与 WebSocket 连接关闭时的行为一致。

```typescript
await docConnection.disconnect({ unloadImmediately: false })
```

请注意，当使用 `unloadImmediately: false` 时，`disconnect()` 返回时不能再保证持久化已经完成。
