Hocuspocus Server Examples
Running Hocuspocus
Command-line interface (CLI)
Sometimes, you just want to spin up a local Hocuspocus instance really fast. Maybe just to give it a try, or to test your webhooks locally. Our CLI brings Hocuspocus to your command line in seconds.
Most likely you just want to run it with the npx command, although you can of course also install it globally or in your project with npm or 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
When you don't call listen() on Hocuspocus, it will not start a WebSocket server itself — you are responsible for calling its handleConnection() method and forwarding messages/close events to the returned ClientConnection.
Since v4, Hocuspocus is built on crossws, so the same integration pattern works across Express, Koa, raw node:http, and other Node frameworks — mount crossws's Node adapter on the server's upgrade event and wire it to 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('Hello World!')
})
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('WebSocket error for peer:', peer.id)
console.error(error)
},
},
})
server.on('upgrade', (request, socket, head) => {
ws.handleUpgrade(request, socket, head)
})
server.listen(1234, () => console.log('Listening on http://127.0.0.1:1234'))IMPORTANT! Some extensions use the onRequest, onUpgrade and onListen hooks, which will not be fired in this scenario.
Hono
Hono is a modern web framework for multiple runtimes. It's a great fit for Hocuspocus as it supports the WebSocket protocol out of the box. Only when running in Node.js, does the Hono implementation requires a bit of extra code to support the WebSocket protocol.
import { Hono } from 'hono'
import { Hocuspocus } from '@hocuspocus/server'
// Node.js specific
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)IMPORTANT! Some extensions use the onUpgrade and onRequest hooks, which will not be fired in this scenario.
Bun
Since v4, Hocuspocus uses crossws under the hood, so it runs beyond Node.js. On Bun, instantiate Hocuspocus directly and bridge crossws to 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) {
// Use peer methods instead of peer.websocket to avoid
// Bun's Proxy `this` binding issue with ServerWebSocket
const wsLike = {
get readyState() {
return peer.websocket.readyState ?? 3; // 3 = CLOSED
},
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("WebSocket error for peer:", 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("Welcome to Hocuspocus!");
},
});Deno
Deno exposes Deno.upgradeWebSocket() and standard WebSocket events, which map cleanly to 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
})The exact plumbing depends on your runtime, but the building blocks are the same: forward the WebSocket, Request, and optional context to handleConnection(), then dispatch incoming messages and close events to the returned ClientConnection.
Koa
Like the Express example above, the v4 recipe uses the crossws Node adapter on the underlying HTTP server:
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 = 'Hello World!'
})
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('WebSocket error for peer:', peer.id)
console.error(error)
},
},
})
server.on('upgrade', (request, socket, head) => {
ws.handleUpgrade(request, socket, head)
})
server.listen(1234)IMPORTANT! Some extensions use the onRequest, onUpgrade and onListen hooks, which will not be fired in this scenario.
PHP / Laravel (Draft)
We've created a Laravel package to make integrating Laravel and Hocuspocus seamless.
You can find details about it here: ueberdosis/hocuspocus-laravel
The primary storage for Hocuspocus must be as a Y.Doc Uint8Array binary. At the moment, there are no compatible PHP libraries to read the YJS format therefore we have two options to access the data: save the data in a Laravel compatible format such as JSON in addition to the primary storage, or create a separate nodejs server with an API to read the primary storage, parse the YJS format and return it to Laravel.
Note: Do not be tempted to store the Y.Doc as JSON and recreate it as YJS binary when the user connects. This will cause issues with merging of updates and content will duplicate on new connections. The data must be stored as binary to make use of the YJS format.
Saving the data in primary storage
Use Laravels migration system to create a table to store the YJS binaries:
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');
}
};
In the Hocuspocus server, you can use the dotenv library to retrieve the DB login details from .env:
import mysql from 'mysql2';
import dotenv from 'dotenv';
dotenv.config()
const pool = mysql.createPool({
connectionLimit: 100, //important
host: '127.0.0.1',
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
debug: false
});
And then use the database extension to store and retrieve the binary using pool.query.
Option 1: Additionally storing the data in another format
Use the webhook extension to send requests to Laravel when the document is updated, with the document in JSON format (see here.
Option 2: Retrieve the data on demand using a separate nodejs daemon (advanced)
Create a nodejs server using the http module:
const server = http.createServer(
...
).listen(3000, '127.0.0.1')
Use the dotenv package as above to retrieve the mysql login details and perform the mysql request. You can then use the YJS library to parse the binary data (Y.applyUpdate(doc, row.data)). You are free to format it in whatever way is needed and then return it to Laravel.
Auth integration
You can use the webhook extension for auth - rejecting the onConnect request will cause the Hocuspocus server to disconnect - however for security critical applications it is better to use a custom onAuthenticate hook as an attacker may be able to retrieve some data from the Hocuspocus server before The onConnect hooks are rejected.
To authenticate with the Laravel server we can use Laravel's built-in authentication system using the session cookie and a CSRF token. Add an onAuthenticate hook to your Hocuspocus server script which passes along the headers (and therefore the session cookie) and add the CSRF token to a request to the Laravel server:
const hocusServer = new Server({
...
onAuthenticate(data) {
return new Promise((resolve, reject) => {
// In v4, requestHeaders is a web-standard Headers object
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()
})
})
},
And add a CSRF token to the request in the provider:
const provider = new HocuspocusProvider({
...
token: '{{ csrf_token() }}',
Finally, add a route in api.php to respond to the request. We can respond with an empty response and just use the request status to verify the authentication (i.e. status code 200 or 403). This example uses the built-in Laravel middleware to verify the session cookie and csrf token. You can add any further middleware here as needed such as verified or any custom middleware:
Route::middleware(['web', 'auth'])->get('/hocus', function (Request $request) {
return response('');
});
That's it!
Editing a document locally
If you want to edit a document directly on the server (while keeping hooks and syncing running), the easiest way is to use Hocuspocus' getDirectConnection method.
const hocuspocus = new Hocuspocus()
const docConnection = await hocuspocus.openDirectConnection('my-document', {})
await docConnection.transact((doc) => {
doc.getMap('test').set('a', 'b')
})
await docConnection.disconnect()