Skip to content

Hocuspocus 4

v4.0.0 Breaking

This release includes 10 breaking changes for platform teams planning a safe upgrade.

Published 1mo Productivity & Wikis
✓ No known CVEs patched
Read the diff → Tool health → What is this tool? →

✓ No known CVEs patched in this version

Topics

collaborative-editing crdt prosemirror real-time self-hosted slatejs
+2 more
tiptap yjs

Affected surfaces

auth breaking_upgrade

Summary

AI summary

Broad release touches Server Changes, Server, non-breaking, and Provider.

Full changelog

Hocuspocus v4.0 Release Notes

Hocuspocus v4 is a major release that brings cross-runtime support, improved type safety, and important bug fixes. This release focuses on making Hocuspocus run beyond Node.js -- on Bun, Deno, Cloudflare Workers, and Node with uWebSockets -- while improving the developer experience with generic Context typing and structured transaction origins.

Backward Compatibility

A v3 provider can connect to a v4 server, and a v4 provider can connect to a v3 server. The wire protocol remains compatible in both directions:

  • v3 provider -> v4 server: The server accepts plain document names (no session routing key), does not require Pong responses, and handles auth messages without a provider version string.
  • v4 provider -> v3 server: The provider defaults to sessionAwareness: false, so it sends plain document names. The extra version string in the auth message is ignored by the v3 server as trailing data. The provider does not require server-initiated Ping messages.
  • Session awareness caveat: If sessionAwareness: true is explicitly enabled on a v4 provider connecting to a v3 server, the server will treat the composite routing key (documentName\0sessionId) as a literal document name, creating unintended documents. Keep sessionAwareness: false (the default) when connecting to a v3 server.

Highlights

Cross-Runtime Support

Hocuspocus is no longer tied to the Node.js ws library. The server now uses crossws, a universal WebSocket adapter, enabling Hocuspocus to run on:

  • Node.js (with ws or uWebSockets.js)
  • Bun
  • Deno
  • Cloudflare Workers

The built-in Server class continues to work as before for Node.js users. For other runtimes, use Hocuspocus directly with handleConnection(), which now accepts any WebSocketLike object and a web-standard Request.

Generic Context Type

All core classes and hook payloads now accept a generic Context type parameter, enabling end-to-end type safety:

interface MyContext {
  userId: string;
  permissions: string[];
}

const server = Server.configure<MyContext>({
  async onAuthenticate({ context, token }) {
    // context is typed as MyContext
    return { userId: '123', permissions: ['read', 'write'] };
  },
  async onChange({ context }) {
    // context.userId is typed as string
    console.log(context.userId);
  },
});

The generic defaults to any, so existing code without explicit typing continues to work.

Ordered Message Processing

Document updates are now processed sequentially in the order they are received. Previously, concurrent messages could be processed out of order if async hooks were involved. A new internal message queue ensures CRDT updates are applied consistently.

Web Standard Request/Headers

Hook payloads now use the web-standard Request and Headers objects instead of Node.js IncomingMessage and IncomingHttpHeaders. This aligns with the cross-runtime goal and provides a consistent API across all environments.

New Features

Server

  • Cross-runtime WebSocket support via crossws -- Bun, Deno, Cloudflare Workers, Node/uWebSockets all supported (non-breaking)
  • Generic Context type parameter on Hocuspocus, Server, Extension, Connection, ClientConnection, DirectConnection, and all hook payloads (non-breaking -- defaults to any)
  • Structured transaction origins -- new TransactionOrigin union type (ConnectionTransactionOrigin | RedisTransactionOrigin | LocalTransactionOrigin) with helper functions isTransactionOrigin() and shouldSkipStoreHooks() (breaking -- see upgrade guide)
  • onLoadDocument now accepts Uint8Array returns -- extensions can return raw Yjs updates instead of constructing a full Y.Doc, simplifying storage extensions (non-breaking)
  • handleConnection() returns ClientConnection -- enables programmatic access to the connection lifecycle for custom integrations (non-breaking)
  • Ordered message processing -- messages are queued and processed sequentially per connection (non-breaking)
  • Session awareness -- the server supports session-aware multiplexing, allowing multiple providers with the same document name on a single WebSocket. Each provider gets a unique sessionId routed via a composite key. The server transparently falls back to plain document names for v3 providers (non-breaking)
  • Auth retry support -- failed authentication now properly cleans up state, allowing clients to retry without reconnecting (non-breaking)
  • DirectConnection context -- openDirectConnection(documentName, context) now accepts and propagates a context object (non-breaking)
  • Store hooks on all changes -- onStoreDocument is now triggered on any document change (not just WebSocket-originated ones), with explicit opt-out via skipStoreHooks on LocalTransactionOrigin (non-breaking)
  • Provider version awareness -- the provider version is available on Connection.providerVersion and in hook payloads (onConnect, onAuthenticate, connected), making it easier to introduce protocol changes in a backward-compatible way (non-breaking)
  • SkipFurtherHooksError -- extensions can throw SkipFurtherHooksError (from @hocuspocus/common) in onStoreDocument to signal that persistence was handled and remaining hooks should be skipped (non-breaking)

Provider

  • Session awareness -- new sessionAwareness option (default: false). When enabled, the provider embeds a unique sessionId in the document name field of every message, enabling multiple providers with the same document name on one WebSocket. Keep disabled when connecting to a v3 server (non-breaking)
  • Provider version sent during auth -- the provider now sends its package version to the server in the authentication message. The extra field is safely ignored by v3 servers (non-breaking)
  • Awareness message deduplication -- when the WebSocket is not yet open and messages are queued, duplicate awareness messages for the same document are deduplicated, keeping only the latest one (non-breaking)
  • Application-level Ping/Pong -- new MessageType.Ping (9) and MessageType.Pong (10). The provider responds to server Ping messages with Pong, replacing WebSocket-level ping/pong which is not available in all runtimes. The provider works fine without receiving Pings (e.g., when connected to a v3 server) (non-breaking)
  • ws package types removed -- the provider no longer imports Event, MessageEvent, or CloseEvent from the ws package. It uses web-standard types and types from @hocuspocus/common instead (breaking for TypeScript users who relied on ws types being re-exported)
  • CloseEvent shape simplified -- the CloseEvent passed to onClose callbacks no longer includes target and type fields. Only code and reason remain (breaking if your onClose handler reads event.target or event.type)
  • Attach collision detection -- HocuspocusProviderWebsocket.attach() now throws an error if you try to attach two authenticated providers with the same effective name. Previously it silently overwrote the existing provider (non-breaking for correct usage; may surface existing bugs)
  • Unknown message types handled gracefully -- unknown message types now log console.error instead of throwing. This makes rolling out future protocol additions easier (non-breaking)

Bug Fixes

Server

  • Auth state reset on failure -- when authentication fails, document state is cleaned up so the client can send a new auth message without reconnecting (#944) (non-breaking)
  • onLoadDocument accepts Uint8Array -- the callback now correctly handles both Y.Doc and Uint8Array returns (#795, #271) (non-breaking)
  • Close code type check -- close event codes are now properly validated as numbers (#1062) (non-breaking)
  • Store hooks reliability -- onStoreDocument now triggers on any document change, preventing accidental data loss when updates lacked a Yjs origin (non-breaking)
  • onStoreDocument payload type -- the Database extension and Logger extension now correctly type the parameter as onStoreDocumentPayload instead of the incorrect onChangePayload / onDisconnectPayload (non-breaking)
  • Document name validation -- empty and whitespace-only document names are now rejected on both WebSocket connections and direct connections (non-breaking)
  • Store hook retry on failure -- when onStoreDocument hooks throw, the document stays in memory and retries are attempted to avoid data loss (non-breaking)
  • Graceful shutdown flushes pending stores -- Server.destroy() now immediately executes all pending debounced onStoreDocument calls, ensuring documents are persisted before the server exits (even when unloadImmediately: false) (non-breaking)
  • Memory optimization -- outgoing Yjs update messages are now encoded once and shared across connections instead of being re-created per connection (non-breaking)

Provider

  • Unknown message types no longer crash the provider -- console.error instead of throw. This makes future protocol additions easier (non-breaking)

Infrastructure Changes

  • Package manager: migrated from npm to pnpm workspaces
  • Bundler: migrated from Rollup to Rolldown
  • SQLite extension: migrated from sqlite3 to better-sqlite3 (synchronous API, actively maintained)
  • Node.js requirement: >=22 (specified in @hocuspocus/server)
  • Default timeout: increased from 30s to 60s
  • Lerna: upgraded to v9 with pnpm as npm client

Upgrade Guide: v3 to v4

1. Update Dependencies

# Install v4
npm install @hocuspocus/server@^4.0.0 @hocuspocus/provider@^4.0.0

If you use the SQLite extension:

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

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

Server Changes

2. Request and Headers (Breaking)

All hook payloads now use web-standard Request and Headers 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;
}

Key differences:

  • requestHeaders['key'] becomes requestHeaders.get('key')
  • request.socket.remoteAddress is no longer available -- use x-forwarded-for or x-real-ip headers from your reverse proxy
  • request is now a web Request object, not Node.js IncomingMessage

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

3. onStoreDocument Payload (Breaking)

The onStoreDocument and afterStoreDocument payloads have been restructured. Several fields that were 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,           // was: context
  lastTransactionOrigin, // was: transactionOrigin
  document,
  documentName,
  clientsCount,
  instance,
  // removed: requestHeaders, requestParameters, socketId
}) {
  // ...
}

Migration:

  • contextlastContext
  • transactionOriginlastTransactionOrigin
  • requestHeaders, requestParameters, socketId -- removed. If you need these, access them from the lastContext. Note that they contain the context of the last connection that triggered the hook.

4. onAwarenessUpdate Payload (Breaking)

The onAwarenessUpdate payload has been simplified. Connection-specific fields are removed and replaced with the source of the update.

Before (v3)

async onAwarenessUpdate({
  context,
  requestHeaders,
  requestParameters,
  socketId,
  document,
  documentName,
  added, updated, removed, states,
}) {
  // ...
}

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,
  // removed: context, requestHeaders, requestParameters, socketId
}) {
  // Access context through the connection if needed
  const context = connection?.context;
}

5. WebSocket Type Changes (Breaking)

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

Before (v3)

import { WebSocket } from 'ws';

// In your code
const ws: WebSocket = connection.webSocket;

After (v4)

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

// In your code
const ws: WebSocketLike = connection.webSocket;

The WebSocketLike interface is minimal:

interface WebSocketLike {
  send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
  close(code?: number, reason?: string): void;
  readyState: number;
}

6. Server Constructor (Breaking)

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

Before (v3)

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

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

After (v4)

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

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

Server.configure() still works the same way -- just move websocketOptions into the config.

7. Transaction Origin Changes (Breaking)

If you inspect transactionOrigin in hooks (e.g., in onChange), the format has changed from raw values to structured objects.

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, shouldSkipStoreHooks } from '@hocuspocus/server';

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

8. SQLite Extension (Breaking)

The SQLite extension has been migrated from the unmaintained sqlite3 package to better-sqlite3.

What you need to do:

  1. Replace the sqlite3 dependency with better-sqlite3
  2. No changes to your Hocuspocus configuration -- the extension API is the same
  3. Existing SQLite database files are fully compatible (no data migration needed)

If you wrote a custom schema for the SQLite extension, note that better-sqlite3 uses named parameters without the $ prefix:

  • $namename
  • $datadata

9. Custom handleConnection Integrations (Breaking)

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

Before (v3)

import { IncomingMessage } from 'http';

wss.on('connection', (ws: WebSocket, request: IncomingMessage) => {
  hocuspocus.handleConnection(ws, request, context);
});

After (v4)

wss.on('connection', (ws, request: Request) => {
  const clientConnection = hocuspocus.handleConnection(ws, request, context);
  // clientConnection is now returned for programmatic access
});

Key changes:

  • 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
  • You are responsible for calling clientConnection.handleMessage(data) and clientConnection.handleClose(event) if you're not using the built-in Server class

10. Timeout Change (Non-Breaking)

The default connection timeout has increased from 30 seconds to 60 seconds. If you relied on the old default, you can restore it:

const server = Server.configure({
  timeout: 30_000,
});

Provider Changes

11. CloseEvent Shape (Breaking)

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

Before (v3)

onClose({ event }) {
  console.log(event.code, event.reason, event.target, event.type);
}

After (v4)

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

12. ws Package Types Removed (Breaking -- TypeScript Only)

The provider no longer imports or re-exports Event, MessageEvent, or CloseEvent from the ws package. If your TypeScript code relied on these types being available through the provider, import them from @hocuspocus/common or use web-standard types instead.

13. Session Awareness (Non-Breaking)

The provider has a new sessionAwareness option (default: false). When enabled, it embeds a unique sessionId in the document name field of every message, allowing multiple providers with the same document name on a single WebSocket.

No action is needed -- the default is false, which preserves v3 behavior. Enable it only when connecting to a v4 server and you need session multiplexing:

const provider = new HocuspocusProvider({
  url: 'ws://localhost:1234',
  name: 'my-document',
  sessionAwareness: true, // opt-in for v4 server multiplexing
});

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: context -> lastContext, 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 extension: 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 any TypeScript imports that relied on ws types from the provider
  • [ ] Test your extensions and hooks thoroughly

Breaking Changes

  • Hook payloads now use web‑standard `Request` and `Headers`; code must replace `requestHeaders['key']` with `requestHeaders.get('key')` and drop `request.socket.remoteAddress`.
  • `onStoreDocument` payload removed fields `context`, `requestHeaders`, `requestParameters`, `socketId`; renamed `context` → `lastContext` and `transactionOrigin` → `lastTransactionOrigin`.
  • `onAwarenessUpdate` payload now provides structured `transactionOrigin`/`connection` instead of raw connection data; legacy fields removed.
  • WebSocket type changed from `ws.WebSocket` to `WebSocketLike`; all imports must use the new interface definition.
  • Server constructor no longer accepts a separate WebSocket options object; options must be placed in `config.websocketOptions`.
  • `transactionOrigin` format replaced by structured `TransactionOrigin` objects with helper functions (`isTransactionOrigin`, `shouldSkipStoreHooks`).
  • SQLite extension migrated from unmaintained `sqlite3` to actively maintained `better-sqlite3`; custom schema code must adjust named‑parameter syntax (e.g., `$name` → `name`).
  • `handleConnection()` now returns a `ClientConnection` and expects a web‑standard `Request` instead of Node.js `IncomingMessage`.
  • Provider `CloseEvent` no longer includes `target` or `type`; code reading those fields must be updated.
  • `ws` package types (`Event`, `MessageEvent`, `CloseEvent`) are no longer re‑exported by the provider; TypeScript users must import from `@hocuspocus/common` or use web‑standard equivalents.

Weekly OSS security release digest.

The CVE patches and breaking changes that affected production tools this week. One email, every Sunday.

No spam, unsubscribe anytime.

Share this release

Track Hocuspocus 4

Get notified when new releases ship.

Sign up free

About Hocuspocus 4

All releases →

Related context

Related tools

Beta — feedback welcome: [email protected]