snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
461
RELEASE_NOTES_V4.md
Normal file
461
RELEASE_NOTES_V4.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# 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](https://github.com/unjs/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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```bash
|
||||
# Install v4
|
||||
npm install @hocuspocus/server@^4.0.0 @hocuspocus/provider@^4.0.0
|
||||
```
|
||||
|
||||
If you use the SQLite extension:
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```typescript
|
||||
async onAuthenticate({ request, requestHeaders }) {
|
||||
const token = requestHeaders['authorization'];
|
||||
const ip = requestHeaders['x-forwarded-for'];
|
||||
const url = request.url;
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
async onStoreDocument({
|
||||
context,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
socketId,
|
||||
transactionOrigin,
|
||||
document,
|
||||
documentName,
|
||||
clientsCount,
|
||||
instance,
|
||||
}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
async onStoreDocument({
|
||||
lastContext, // was: context
|
||||
lastTransactionOrigin, // was: transactionOrigin
|
||||
document,
|
||||
documentName,
|
||||
clientsCount,
|
||||
instance,
|
||||
// removed: requestHeaders, requestParameters, socketId
|
||||
}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Migration:**
|
||||
- `context` → `lastContext`
|
||||
- `transactionOrigin` → `lastTransactionOrigin`
|
||||
- `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)
|
||||
|
||||
```typescript
|
||||
async onAwarenessUpdate({
|
||||
context,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
socketId,
|
||||
document,
|
||||
documentName,
|
||||
added, updated, removed, states,
|
||||
}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
// In your code
|
||||
const ws: WebSocket = connection.webSocket;
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
import type { WebSocketLike } from '@hocuspocus/server';
|
||||
|
||||
// In your code
|
||||
const ws: WebSocketLike = connection.webSocket;
|
||||
```
|
||||
|
||||
The `WebSocketLike` interface is minimal:
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
import { Server } from '@hocuspocus/server';
|
||||
|
||||
const server = new Server(
|
||||
{ port: 8080, extensions: [...] },
|
||||
{ maxPayload: 1024 * 1024 } // ws options as 2nd arg
|
||||
);
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
async onChange({ transactionOrigin }) {
|
||||
if (transactionOrigin === '__hocuspocus__redis__origin__') {
|
||||
// came from Redis
|
||||
}
|
||||
if (transactionOrigin instanceof Connection) {
|
||||
// came from a client connection
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
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:
|
||||
- `$name` → `name`
|
||||
- `$data` → `data`
|
||||
|
||||
### 9. Custom `handleConnection` Integrations (Breaking)
|
||||
|
||||
If you call `handleConnection()` directly (e.g., for Express/Koa integration), the signature has changed:
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
import { IncomingMessage } from 'http';
|
||||
|
||||
wss.on('connection', (ws: WebSocket, request: IncomingMessage) => {
|
||||
hocuspocus.handleConnection(ws, request, context);
|
||||
});
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
onClose({ event }) {
|
||||
console.log(event.code, event.reason, event.target, event.type);
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
Reference in New Issue
Block a user