Upgrade Guide

Upgrading to 4.0 from 3.x

Hocuspocus v4 brings cross-runtime support (Bun, Deno, Cloudflare Workers, Node with uWebSockets), improved type safety via a generic Context type, and structured transaction origins.

The wire protocol is backward compatible in both directions: v3 providers can connect to v4 servers and vice versa. See the v4 release notes for full details.

1. Update dependencies

npm install @hocuspocus/server@^4.0.0 @hocuspocus/provider@^4.0.0

Node.js requirement: v4 requires Node.js 22 or later.

If you use the SQLite extension, replace sqlite3 with better-sqlite3:

npm uninstall sqlite3
npm install @hocuspocus/extension-sqlite@^4.0.0 better-sqlite3
npm install -D @types/better-sqlite3

Existing SQLite database files are fully compatible — no data migration needed.

2. Web-standard Request and Headers (Breaking)

All hook payloads now use the web-standard Request and Headers objects instead of Node.js IncomingMessage and IncomingHttpHeaders.

Before (v3):

async onAuthenticate({ request, requestHeaders }) {
  const token = requestHeaders['authorization']
  const ip = requestHeaders['x-forwarded-for']
  const url = request.url
}

After (v4):

async onAuthenticate({ request, requestHeaders }) {
  const token = requestHeaders.get('authorization')
  const ip = requestHeaders.get('x-forwarded-for')
  const url = request.url
}

request.socket.remoteAddress is no longer available — use x-forwarded-for or x-real-ip headers from your reverse proxy instead.

The onUpgrade and onRequest hooks still use Node.js IncomingMessage/ServerResponse because they operate at the HTTP level before the WebSocket upgrade.

3. onStoreDocument payload (Breaking)

The onStoreDocument and afterStoreDocument payloads have been restructured. Fields tied to a specific connection have been removed since store hooks can now be triggered by non-connection sources.

Before (v3):

async onStoreDocument({ context, requestHeaders, requestParameters, socketId, transactionOrigin, document, documentName, clientsCount, instance }) {
  // ...
}

After (v4):

async onStoreDocument({ lastContext, lastTransactionOrigin, document, documentName, clientsCount, instance }) {
  // `context` → `lastContext`
  // `transactionOrigin` → `lastTransactionOrigin`
  // `requestHeaders`, `requestParameters`, `socketId` — removed. Access via `lastContext` if needed.
}

4. onAwarenessUpdate payload (Breaking)

Connection-specific fields are removed and replaced with the source of the update.

After (v4):

async onAwarenessUpdate({
  transactionOrigin, // new: structured origin
  connection,        // new: optional, the connection that triggered the update
  document,
  documentName,
  added, updated, removed,
  awareness,         // new: the Awareness instance
  states,
}) {
  // Access context through the connection if needed
  const context = connection?.context
}

5. Transaction origin (Breaking)

Transaction origins are now structured objects rather than raw values.

Before (v3):

async onChange({ transactionOrigin }) {
  if (transactionOrigin === '__hocuspocus__redis__origin__') {
    // came from Redis
  }
  if (transactionOrigin instanceof Connection) {
    // came from a client connection
  }
}

After (v4):

import { isTransactionOrigin } from '@hocuspocus/server'

async onChange({ transactionOrigin }) {
  if (isTransactionOrigin(transactionOrigin)) {
    switch (transactionOrigin.source) {
      case 'redis':
        // came from Redis
        break
      case 'connection':
        // transactionOrigin.connection is available
        break
      case 'local':
        // came from server-side code (e.g. DirectConnection)
        break
    }
  }
}

6. WebSocket options (Breaking)

WebSocket options are now passed inside the configuration object instead of as a separate parameter.

Before (v3):

const server = new Server(
  { port: 8080, extensions: [...] },
  { maxPayload: 1024 * 1024 }, // ws options as 2nd arg
)

After (v4):

const server = new Server({
  port: 8080,
  extensions: [...],
  websocketOptions: { maxPayload: 1024 * 1024 },
})

7. WebSocket type changes (Breaking)

If your code references the WebSocket type from the ws package, update to WebSocketLike:

import type { WebSocketLike } from '@hocuspocus/server'

const ws: WebSocketLike = connection.webSocket

WebSocketLike is a minimal interface with send, close, and readyState — compatible with all supported runtimes.

8. Custom handleConnection integrations (Breaking)

If you call handleConnection() directly (e.g. for Express/Koa integration), the signature has changed:

After (v4):

wss.on('connection', (ws, request: Request) => {
  const clientConnection = hocuspocus.handleConnection(ws, request, context)
  // clientConnection is now returned for programmatic access
})
  • request must be a web-standard Request (not IncomingMessage)
  • The method now returns a ClientConnection instance
  • The WebSocket no longer needs to be from the ws package — any WebSocketLike works
  • If you are not using the built-in Server class, you are responsible for calling clientConnection.handleMessage(data) and clientConnection.handleClose(event)

9. Provider CloseEvent shape (Breaking)

The CloseEvent passed to onClose callbacks no longer includes target and type. Only code and reason remain.

onClose({ event }) {
  console.log(event.code, event.reason)
  // event.target and event.type are no longer available
}

10. Provider ws package types removed (TypeScript only)

The provider no longer re-exports Event, MessageEvent, or CloseEvent from the ws package. Import them from @hocuspocus/common or use web-standard types instead.

11. Timeout change (Non-breaking)

The default connection timeout has increased from 30 seconds to 60 seconds. To keep the old behavior:

const server = new Server({
  timeout: 30_000,
})

Summary checklist

Server

  • Update to Node.js 22+
  • Update all @hocuspocus/* packages to v4
  • Replace requestHeaders['key'] with requestHeaders.get('key') in all hooks
  • Replace request.socket.remoteAddress with proxy headers (x-forwarded-for)
  • Update onStoreDocument handlers: contextlastContext, remove requestHeaders/requestParameters/socketId
  • Update onAwarenessUpdate handlers if used
  • Replace WebSocket type imports from ws with WebSocketLike from @hocuspocus/server
  • Move websocketOptions into the Server configuration object
  • Update transaction origin checks to use isTransactionOrigin() and .source
  • If using SQLite: replace sqlite3 with better-sqlite3
  • If using custom handleConnection: update to new signature and Request type

Provider

  • Update @hocuspocus/provider to v4
  • Remove references to event.target / event.type in onClose handlers
  • Update TypeScript imports that relied on ws types from the provider

Upgrading to 3.0 from 2.x (Provider)

The TiptapCollabProvider has been moved to @tiptap-pro/provider.

When using multiplexing, the providers now need to explicitly call attach to attach to the websocket.

import {
  TiptapCollabProvider,
  TiptapCollabProviderWebsocket
} from "@tiptap-pro/provider";

const socket = new TiptapCollabProviderWebsocket({
  appId: '', // or `url` if using `HocuspocusProviderWebsocket`
})

const provider1 = new TiptapCollabProvider({
  websocketProvider: socket,
  name: 'document1',
  token: '',
})

provider1.attach() // when passing a socket manually, you need to call attach explicitly

More information can be found here: https://github.com/ueberdosis/hocuspocus/releases/tag/v3.1.0

Upgrading to 3.0 from 2.x (Server)

With the upgrade to the new version, the initialization of hocuspocus has changed. As described on the usage side, there are two ways on how you can use hocuspocus. With the built-in server. Or like a library with other frameworks (like express). To make things simpler and enable more features in the future, we separated classes and put the server into its own class.

Usage with .configure()

It is no longer possible to use hocuspocus with .configure(). You always have to create a new instance by yourself.

Old Way

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  port: 1234,
});

server.listen();

New Way

import { Server } from "@hocuspocus/server";

const server = new Server({
  port: 1234,
});

server.listen();

Notice, that the import has not changed. The configuration options stay the same here.

Usage of Hocuspocus without built-in server

If you have used Hocuspocus without the built-in server before, you have to update your setup as well.

Old Way

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  // ...
});

New Way

import { Hocuspocus } from "@hocuspocus/server";

const hocuspocus = new Hocuspocus({
  // ...
});

// You still use handleConnection as you did before.
hocuspocus.handleConnection(...);

Notice the change of the import from Server to Hocuspocus as well as the initialization with new Hocuspocus(). See examples for more on that.

Change of the servers listen signature

The .listen() function of the server was quite versatile. We simplified the signature of it while you can still reach the same behavior as before.

Old Signature

async listen(
    portOrCallback: number | ((data: onListenPayload) => Promise<any>) | null = null,
    callback: any = null,
): Promise<Hocuspocus>

New Signature

async listen(port?: number, callback: any = null): Promise<Hocuspocus>

The listen method still returns a Promise which will be resolved to Hocuspocus, if nothing fails.

Both the callbacks you could provide in the old version were added to the onListen hook. This is still the case with the callback on the new version. But you can't provide just a callback on the first parameter anymore. If you just want to add a callback you also still can add it within the configuration of the server.

import { Server } from "@hocuspocus/server";

const server = new Server({
  async onListen(data) {
    console.log(`Server is listening on port "${data.port}"!`);
  },
});

server.listen()