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

This commit is contained in:
upstream-mirror
2026-09-15 17:21:35 +05:00
commit e0c588ca1c
265 changed files with 55259 additions and 0 deletions

68
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,68 @@
inheritance: true
reviews:
request_changes_workflow: true
high_level_summary_instructions:
Short and simple, keep it cohesive, no complex wording, assume everyone including the
PR reviewer is new to the project or to code in general - simple english so even non-english native speakers
understand everything just fine.
high_level_summary_in_walkthrough: true
in_progress_fortune: false
slop_detection:
label: slop
suggested_labels: true
auto_apply_labels: true
labeling_instructions:
- label: 'complexity: easy'
instructions: Apply this label if the PR makes a clearly scoped change in one isolated component or package.
- label: 'complexity: expert'
instructions: Apply this label if the PR changes fundamental architecture, core APIs, or collaboration behavior.
- label: 'complexity: hard'
instructions: Apply this label if the PR changes Y.js document synchronization, awareness protocol, or WebSocket provider internals.
- label: 'complexity: medium'
instructions: Apply this label if the PR changes multiple components or the Y.js/Hocuspocus integration with a clear implementation path.
- label: 'complexity: trivial'
instructions: Apply this label if the PR is an obvious change under 10 lines, such as a documentation typo or missing type check.
- label: good first issue
instructions: Apply this label if the PR is a small, self-contained change that is easy for a first-time contributor to understand.
- label: 'impact: critical'
instructions: Apply this label if the PR fixes data loss, a security vulnerability, broken core functionality, or a problem affecting nearly all users of a feature.
- label: 'impact: high'
instructions: Apply this label if the PR fixes behavior affecting a large portion of users or a common workflow.
- label: 'impact: low'
instructions: Apply this label if the PR fixes an obscure edge case, a specific browser condition, or a rarely used feature.
- label: 'impact: medium'
instructions: Apply this label if the PR fixes a common feature usage or a workflow shared by a group of users.
auto_review:
ignore_usernames:
- github-actions[bot]
issue_enrichment:
auto_enrich:
enabled: true
labeling:
auto_apply_labels: true
labeling_instructions:
- label: 'complexity: easy'
instructions: 'clearly described, affects one isolated component, fix localized to a single package'
- label: 'complexity: expert'
instructions: 'fundamental architecture conflict, core API changes affecting all extensions, or concurrent/collaboration behavior'
- label: 'complexity: hard'
instructions: 'requires deep understanding of Y.js document sync, awareness protocol, or WebSocket provider internals; no obvious solution'
- label: 'complexity: medium'
instructions: 'touches multiple components or requires understanding Y.js/Hocuspocus interaction, but solution path is clear'
- label: 'complexity: trivial'
instructions: 'typo in docs, missing type check, obvious fix in <10 lines'
- label: good first issue
instructions:
Apply this label if the issue is a good ticket to pick up as a first-time contributor or beginner at
Hocuspocus or Y.js. Good candidates are well-scoped bugs with clear reproduction steps,
documentation improvements, or small feature requests with a clear implementation path.
- label: 'impact: critical'
instructions: 'data loss, security vulnerability, completely broken core functionality, or affects nearly everyone using a specific feature'
- label: 'impact: high'
instructions: 'affects a large portion of users, blocks a common use case, or causes noticeable UX problem many will run into'
- label: 'impact: low'
instructions: 'obscure edge case, specific browser under specific conditions, or feature request very few would use'
- label: 'impact: medium'
instructions: 'affects a common usage of a feature (e.g. specific extension combination) or handful of users with similar workflows'

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: http://EditorConfig.org
root = true
[*]
end_of_line = lf
charset = utf-8
insert_final_newline = true
max_line_length = 100
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

2
.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
**/dist/**
**/node_modules/**

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve Hocuspocus
url: https://github.com/ueberdosis/hocuspocus/issues/new
title: ''
labels:
- bug
assignees:
- janthurau
---
**Description**
A clear and concise description of what the bug is.
**Steps to reproduce the bug**
Steps to reproduce the behavior:
1. Go to …
2. Type in …
3. Click on …
4. See error message
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshot, video, or GIF**
If applicable, add screenshots to help explain your problem.
**Environment?**
- operating system:
- browser:
- mobile/desktop:
- Hocuspocus version:
**Additional context**
Add any other context about the problem here.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discuss hocuspocus on GitHub
url: https://github.com/ueberdosis/hocuspocus/discussions/new
about: Help, discussion about best practices, or any other conversation that would benefit from being searchable
- name: Join the hocuspocus Discord server
url: https://discord.gg/WtJ49jGshW
about: Ccasual chit-chat with others using hocuspocus

View File

@@ -0,0 +1,21 @@
---
name: Feature request
about: Suggest a new feature for Hocuspocus
title: ''
labels:
- feature request
assignees:
- janthurau
---
**The problem I am facing**
A clear and concise description of what the problem is. For example: I’m always frustrated when …
**The solution I would like**
A clear and concise description of what you want to happen.
**Alternatives I have considered**
A clear and concise description of any alternative solutions or features you have considered.
**Additional context**
Add any other context or screenshots here.

22
.github/ISSUE_TEMPLATE/feedback.md vendored Normal file
View File

@@ -0,0 +1,22 @@
---
name: Feedback on the documentation
about: Share what we need to explain better
url: https://github.com/ueberdosis/hocuspocus/issues/new
title: ''
labels:
- documentation
assignees:
- janthurau
---
**Part of the documentation?**
I’ve read the following page of the documentation …
**Really helpful parts**
I think this part is really good: …
**Hard to understand, missing or misleading**
But you really need to improve …
**Additional context**
Add any other context or screenshots here.

78
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
# Dependabot creates pull requests to keep your dependencies secure and up-to-date.
# Documentation: https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
open-pull-requests-limit: 10
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/cli"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/common"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/extension-database"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/extension-logger"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/extension-redis"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/extension-sqlite"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/extension-throttle"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/extension-webhook"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/provider"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/server"
schedule:
interval: 'weekly'
day: 'monday'
- package-ecosystem: "npm"
directory: "packages/transformer"
schedule:
interval: 'weekly'
day: 'monday'

109
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
# Automate, customize, and execute your software development workflows right in your repository with GitHub Actions.
# Documentation: https://docs.github.com/en/actions
name: build
on:
push:
branches:
- main
pull_request:
branches:
- '**'
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 23]
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Set up npmrc
run: echo "@tiptap-cloud:registry=https://registry.tiptap.dev/" >> ~/.npmrc && echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_PRIVATE_REGISTRY_NPM_TOKEN }}" >> ~/.npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
# - name: Lint code
# run: pnpm lint
test:
runs-on: ubuntu-latest
services:
redis:
image: redis
ports:
- 6379/tcp
options: --entrypoint redis-server
strategy:
matrix:
node-version: [22, 23]
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Set up npmrc
run: echo "@tiptap-cloud:registry=https://registry.tiptap.dev/" >> ~/.npmrc && echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_PRIVATE_REGISTRY_NPM_TOKEN }}" >> ~/.npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
env:
REDIS_HOST: localhost
REDIS_PORT: ${{ job.services.redis.ports[6379] }}
build:
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
node-version: [22, 23]
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Set up npmrc
run: echo "@tiptap-cloud:registry=https://registry.tiptap.dev/" >> ~/.npmrc && echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_PRIVATE_REGISTRY_NPM_TOKEN }}" >> ~/.npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Try to build the packages
run: pnpm build:packages

27
.github/workflows/docsearch.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# Automate, customize, and execute your software development workflows right in your repository with GitHub Actions.
# Documentation: https://docs.github.com/en/actions
name: docsearch
on:
workflow_dispatch:
schedule:
- cron: '5 1 * * *'
jobs:
docsearch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run DocSearch Scraper
shell: bash
run: |
docker run \
-e TYPESENSE_API_KEY=${{ secrets.TYPESENSE_API_KEY }} \
-e TYPESENSE_HOST="${{ secrets.TYPESENSE_HOST }}" \
-e TYPESENSE_PORT="${{ secrets.TYPESENSE_PORT }}" \
-e TYPESENSE_PROTOCOL="${{ secrets.TYPESENSE_PROTOCOL }}" \
-e CONFIG="$(cat docsearch.config.json | jq -r tostring)" \
typesense/docsearch-scraper

39
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages
name: Publish to NPM
on:
push:
tags:
- '*'
permissions:
id-token: write
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.10
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: https://registry.npmjs.org/
cache: 'pnpm'
- name: Set up npmrc
id: setup-npmrc
run: echo "@tiptap-cloud:registry=https://registry.tiptap.dev/" >> ~/.npmrc && echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_PRIVATE_REGISTRY_NPM_TOKEN }}" >> ~/.npmrc
- run: pnpm install --frozen-lockfile
- run: pnpm publish:latest
if: "!contains(github.ref, '-rc.')"
- run: pnpm publish:pre
if: "contains(github.ref, '-rc.')"

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
*.log
.cache
.DS_Store
.env
.env.*
.temp
dist
node_modules
.idea
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/database
.prettierignore
package-lock.json
yarn.lock

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}

1432
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023, Tiptap GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

128
README.md Normal file
View File

@@ -0,0 +1,128 @@
> [!TIP]
> Help us **chart the future of Hocuspocus** by telling us about your needs in a short survey. Plus, you’ll get a chance to earn a $50 gift card.
> **[Take the survey →](https://docs.google.com/forms/d/e/1FAIpQLSeUS8S1iZTud58GBSD_d4c8lfUvkZHvzU99kWxP1wnNZnPKpw/viewform?usp=header)**
# Hocuspocus
A plug & play collaboration backend based on [Y.js](https://github.com/yjs/yjs).
[![Build Status](https://github.com/ueberdosis/hocuspocus/workflows/build/badge.svg)](https://github.com/ueberdosis/hocuspocus/actions)
[![Version](https://img.shields.io/npm/v/@hocuspocus/server.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/server)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/server.svg)](https://npmcharts.com/compare/@hocuspocus/server?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/server.svg)](https://www.npmjs.com/package/@hocuspocus/server)
[![Chat](https://img.shields.io/badge/chat-on%20discord-7289da.svg?sanitize=true)](https://discord.gg/WtJ49jGshW)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Documentation
The full documentation is a available on [hocuspocus.dev/introduction](https://tiptap.dev/docs/hocuspocus/introduction).
## Cloud Hosting
You want to use Hocuspocus, but don't want to care about hosting? Check our [Cloud Offering: Tiptap Collab](https://tiptap.dev/collab)
## Feedback
Send all your questions, feedback and bug reports to [humans@tiptap.dev](mailto:humans@tiptap.dev) or [create an issue](https://github.com/ueberdosis/hocuspocus/issues/new/choose) here.
## Usage
The following example is a example setup you need to start a WebSocket server. By default, it’s listening on [http://127.0.0.1](http://127.0.0.1) (or prefixed with the WebSocket protocol on ws://127.0.0.1):
```js
import { Server } from '@hocuspocus/server'
import { SQLite } from '@hocuspocus/extension-sqlite'
const server = new Server({
port: 1234,
async onConnect() {
console.log('🔮')
},
extensions: [
new SQLite({
database: 'db.sqlite',
}),
],
});
server.listen();
```
## Community
For help, discussion about best practices, or any other conversation:
[Join the Tiptap Discord Server](https://discord.gg/WtJ49jGshW)
## Sponsors 💖
<table>
<tr>
<td align="center">
<a href="https://tiptap.dev/">
<img src="https://unavatar.io/github/ueberdosis" width="100"><br>
<strong>Tiptap</strong>
</a>
</td>
<td align="center">
<a href="https://cargo.site/">
<img src="https://unavatar.io/github/cargo" width="100"><br>
<strong>Cargo</strong>
</a>
</td>
<td align="center">
<a href="https://saga.so/">
<img src="https://unavatar.io/saga.so" width="100"><br>
<strong>Saga</strong>
</a>
</td>
<td align="center">
<a href="https://www.gamma.app/">
<img src="https://unavatar.io/gamma.app" width="100"><br>
<strong>Gamma</strong>
</a>
</td>
<td align="center">
<a href="https://www.getoutline.com/">
<img src="https://unavatar.io/github/outline" width="100"><br>
<strong>Outline</strong>
</a>
</td>
<td align="center">
<a href="https://ahrefs.com/">
<img src="https://unavatar.io/ahrefs.com" width="100"><br>
<strong>Ahrefs</strong>
</a>
</td>
<td align="center">
<a href="https://github.com/brickdoc">
<img src="https://unavatar.io/github/brickdoc" width="100"><br>
<strong>Brickdoc</strong>
</a>
</td>
<td align="center">
<a href="https://www.sanalabs.com/">
<img src="https://unavatar.io/github/sanalabs" width="100"><br>
<strong>Sana</strong>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://poggio.io">
<img src="https://unavatar.io/github/poggiolabs" width="100"><br>
<strong>Poggio</strong>
</a>
</td>
</tr>
</table>
… and hundreds of awesome inviduals.
Using Hocuspocus in production? Invest in the future of Hocuspocus and [become a sponsor!](https://github.com/sponsors/ueberdosis)
## Contributing
Please see [CONTRIBUTING](docs/contributing.md) for details.
## Contributors
[kris](https://github.com/kriskbx) (who wrote the initial version), [Tom Moor](https://github.com/tommoor), [YousefED (@TypeCellOS)](https://github.com/YousefED) and [many more](../../contributors).
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

461
RELEASE_NOTES_V4.md Normal file
View 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` &rarr; `lastContext`
- `transactionOrigin` &rarr; `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` &rarr; `name`
- `$data` &rarr; `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

8
babel.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
presets: [
'@babel/preset-env',
],
plugins: [
'@babel/plugin-transform-class-properties',
],
}

18
biome.json Normal file
View File

@@ -0,0 +1,18 @@
{
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"linter": {
"rules": {
"suspicious": {
"noAsyncPromiseExecutor": "off",
"noExplicitAny": "off"
},
"complexity": {
"noForEach": "off"
}
}
}
}

7
docker-compose.yml Normal file
View File

@@ -0,0 +1,7 @@
version: "3.9"
services:
redis:
image: "redis:6-alpine"
ports:
- "6379:6379"

37
docsearch.config.json Normal file
View File

@@ -0,0 +1,37 @@
{
"index_name": "hocuspocus",
"start_urls": [
"https://tiptap.dev/docs/hocuspocus"
],
"sitemap_alternate_links": true,
"selectors": {
"default": {
"lvl0": {
"selector": "",
"global": true,
"default_value": "Documentation"
},
"lvl1": "main h1",
"lvl2": "main h2",
"lvl3": "main h3",
"lvl4": "main h4",
"lvl5": "main h5",
"lvl6": "main h6",
"text": "main p, main li, main pre, main td"
}
},
"strip_chars": " .,;:#",
"custom_settings": {
"separatorsToIndex": "_",
"attributesForFaceting": [
"type",
"lang"
],
"attributesToRetrieve": [
"hierarchy",
"text",
"anchor",
"url"
]
}
}

13
lerna.json Normal file
View File

@@ -0,0 +1,13 @@
{
"packages": [
"packages/*"
],
"npmClient": "pnpm",
"command": {
"publish": {
"conventionalCommits": true
}
},
"version": "4.7.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}

79
package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"browserslist": [
"defaults",
"not IE 11",
"maintained node versions"
],
"type": "module",
"ava": {
"files": [
"tests/**/*",
"!tests/utils/**/*",
"!database/**/*"
],
"extensions": {
"ts": "module"
},
"require": [
"./tests/utils/_force-exit.ts"
],
"nodeArguments": [
"--no-warnings",
"--conditions=source",
"--experimental-transform-types",
"--experimental-specifier-resolution=node"
],
"timeout": "30s",
"workerThreads": false
},
"scripts": {
"start": "pnpm playground",
"playground": "pnpm playground:default",
"playground:load-document": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/load-document.ts\"",
"playground:express": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/express.ts\"",
"playground:koa": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/koa.ts\"",
"playground:default": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/default.ts\"",
"playground:slow": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/slow.ts\"",
"playground:redis": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/redis.ts\"",
"playground:webhook": "concurrently --kill-others \"pnpm -C ./playground/frontend dev\" \"pnpm -C ./playground/backend dev src/webhook.ts\"",
"lint:ts": "tsc --noEmit -p tsconfig.json --composite false",
"lint": "biome lint && pnpm lint:ts",
"lint:fix": "biome lint --fix",
"test": "ava",
"test:watch": "ava --watch",
"build:packages": "pnpm clean:packages && rolldown -c",
"build:watch": "pnpm clean:packages && rolldown --watch -c",
"clean:packages": "rm -Rf ./packages/*/dist",
"release:major": "pnpm build:packages && lerna version major --force-publish --sign-git-tag --sign-git-commit",
"release:major:pre": "pnpm build:packages && lerna version premajor --force-publish --preid rc --sign-git-tag --sign-git-commit",
"release:minor": "pnpm build:packages && lerna version minor --force-publish --sign-git-tag --sign-git-commit",
"release:minor:pre": "pnpm build:packages && lerna version preminor --force-publish --preid rc --sign-git-tag --sign-git-commit",
"release:patch": "pnpm build:packages && lerna version patch --force-publish --sign-git-tag --sign-git-commit",
"release:patch:pre": "pnpm build:packages && lerna version prepatch --force-publish --preid rc --sign-git-tag --sign-git-commit",
"release:pre": "pnpm build:packages && lerna version prerelease --force-publish --preid rc --sign-git-tag --sign-git-commit",
"publish:latest": "pnpm build:packages && lerna publish from-package --yes",
"publish:pre": "pnpm build:packages && lerna publish from-package --yes --dist-tag next",
"reset": "pnpm clean:packages && rm -Rf ./**/.cache && rm -Rf ./**/.temp && rm -Rf ./**/node_modules && rm -Rf ./pnpm-lock.yaml && pnpm install",
"ncu": "ncu -i --deep && pnpm install"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@types/bun": "^1.3.9",
"ava": "^7.0.0",
"concurrently": "^6.5.1",
"lerna": "^9.0.5",
"nx": "^22.7.8",
"rolldown": "1.0.0-rc.6",
"rolldown-plugin-dts": "^0.22.3",
"typescript": "5.8.2"
},
"engines": {
"node": ">=22"
},
"name": "hocuspocus",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
}
}

1273
packages/cli/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

18
packages/cli/README.md Normal file
View File

@@ -0,0 +1,18 @@
# @hocuspocus/cli
[![Version](https://img.shields.io/npm/v/@hocuspocus/cli.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/cli)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/cli.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/cli.svg)](https://www.npmjs.com/package/@hocuspocus/cli)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
A CLI tool to start a local [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server – useful for quickly spinning up a collaboration backend for development, demos, or simple deployments, with optional SQLite, S3, logger, and webhook extensions.
```bash
npx @hocuspocus/cli
```
## Official Documentation
Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus).
## License
Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

35
packages/cli/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@hocuspocus/cli",
"description": "a CLI tool to start a local Hocuspocus server",
"version": "4.7.0",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"yjs",
"yjs-websocket"
],
"license": "MIT",
"type": "module",
"bin": {
"hocuspocus": "./src/index.js",
"@hocuspocus/cli": "./src/index.js"
},
"files": [
"src"
],
"dependencies": {
"@hocuspocus/extension-logger": "workspace:^",
"@hocuspocus/extension-s3": "workspace:^",
"@hocuspocus/extension-sqlite": "workspace:^",
"@hocuspocus/extension-webhook": "workspace:^",
"@hocuspocus/server": "workspace:^",
"meow": "^14.0.0"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

150
packages/cli/src/index.js Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
import { Logger } from "@hocuspocus/extension-logger";
import { S3 } from "@hocuspocus/extension-s3";
import { SQLite } from "@hocuspocus/extension-sqlite";
import { Webhook } from "@hocuspocus/extension-webhook";
import { Server } from "@hocuspocus/server";
import meow from "meow";
export const cli = meow(
`
Usage
$ hocuspocus [options]
Options
--port=, -p Set the port, defaults to 1234.
--webhook=, -w Configure a custom webhook.
--sqlite=, -s Store data in a SQLite database, defaults to :memory:.
--s3 Store data in S3 or S3-compatible storage.
--s3-bucket= S3 bucket name (required when using --s3).
--s3-region= S3 region, defaults to us-east-1.
--s3-prefix= S3 key prefix for documents.
--s3-endpoint= S3 endpoint URL (for S3-compatible services like MinIO).
--version Show the current version number.
Examples
$ hocuspocus --port 8080
$ hocuspocus --webhook http://localhost/webhooks/hocuspocus
$ hocuspocus --sqlite
$ hocuspocus --sqlite database/default.sqlite
$ hocuspocus --s3 --s3-bucket my-docs
$ hocuspocus --s3 --s3-bucket my-docs --s3-region eu-west-1
$ hocuspocus --s3 --s3-bucket my-docs --s3-endpoint http://localhost:9000
Environment Variables (for S3)
AWS_ACCESS_KEY_ID AWS access key ID
AWS_SECRET_ACCESS_KEY AWS secret access key
AWS_REGION AWS region (alternative to --s3-region)
`,
{
importMeta: import.meta,
flags: {
port: {
type: "string",
shortFlag: "p",
default: "1234",
},
webhook: {
type: "string",
shortFlag: "w",
default: "",
},
sqlite: {
type: "string",
shortFlag: "s",
default: "",
},
s3: {
type: "boolean",
default: false,
},
s3Bucket: {
type: "string",
default: "",
},
s3Region: {
type: "string",
default: "us-east-1",
},
s3Prefix: {
type: "string",
default: "",
},
s3Endpoint: {
type: "string",
default: "",
},
},
},
);
export const getConfiguredWebhookExtension = () => {
return cli.flags.webhook
? new Webhook({
url: cli.flags.webhook,
})
: undefined;
};
export const getConfiguredSQLiteExtension = () => {
if (cli.flags.sqlite) {
return new SQLite({
database: cli.flags.sqlite,
});
}
if (process.argv.includes("--sqlite")) {
return new SQLite();
}
return undefined;
};
export const getConfiguredS3Extension = () => {
if (!cli.flags.s3) {
return undefined;
}
const bucket = cli.flags.s3Bucket || process.env.S3_BUCKET;
if (!bucket) {
console.error(
"❌ S3 bucket is required. Use --s3-bucket or set S3_BUCKET environment variable.",
);
process.exit(1);
}
const config = {
bucket,
region: cli.flags.s3Region || process.env.AWS_REGION || "us-east-1",
};
if (cli.flags.s3Prefix) {
config.prefix = cli.flags.s3Prefix;
}
if (cli.flags.s3Endpoint) {
config.endpoint = cli.flags.s3Endpoint;
config.forcePathStyle = true;
}
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
config.credentials = {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
};
}
return new S3(config);
};
const server = new Server({
port: Number.parseInt(cli.flags.port, 10),
extensions: [
new Logger(),
getConfiguredWebhookExtension(),
getConfiguredSQLiteExtension(),
getConfiguredS3Extension(),
].filter((extension) => extension),
});
server.listen();

1195
packages/common/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

14
packages/common/README.md Normal file
View File

@@ -0,0 +1,14 @@
# @hocuspocus/common
[![Version](https://img.shields.io/npm/v/@hocuspocus/common.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/common)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/common.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/common.svg)](https://www.npmjs.com/package/@hocuspocus/common)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Shared types and utilities used across [Hocuspocus](https://github.com/ueberdosis/hocuspocus) packages – including message/sync protocol helpers and common enums. This package is an internal building block; most users will depend on `@hocuspocus/server` or `@hocuspocus/provider` instead.
## Official Documentation
Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus).
## License
Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,38 @@
{
"name": "@hocuspocus/common",
"description": "shared code for multiple Hocuspocus packages",
"version": "4.7.0",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-common.cjs",
"module": "dist/hocuspocus-common.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-common.esm.js",
"require": "./dist/hocuspocus-common.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"gitHead": "cd788b6a315f608ef531524409abdce1e6790726",
"dependencies": {
"lib0": "^0.2.117"
},
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,49 @@
export interface CloseEvent {
code: number;
reason: string;
}
/**
* The server is terminating the connection because a data frame was received
* that is too large.
* See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
*/
export const MessageTooBig: CloseEvent = {
code: 1009,
reason: "Message Too Big",
};
/**
* The server successfully processed the request, asks that the requester reset
* its document view, and is not returning any content.
*/
export const ResetConnection: CloseEvent = {
code: 4205,
reason: "Reset Connection",
};
/**
* Similar to Forbidden, but specifically for use when authentication is required and has
* failed or has not yet been provided.
*/
export const Unauthorized: CloseEvent = {
code: 4401,
reason: "Unauthorized",
};
/**
* The request contained valid data and was understood by the server, but the server
* is refusing action.
*/
export const Forbidden: CloseEvent = {
code: 4403,
reason: "Forbidden",
};
/**
* The server timed out waiting for the request.
*/
export const ConnectionTimeout: CloseEvent = {
code: 4408,
reason: "Connection Timeout",
};

View File

@@ -0,0 +1,6 @@
export class SkipFurtherHooksError extends Error {
constructor(message?: string) {
super(message ?? "Further hooks skipped");
this.name = "SkipFurtherHooksError";
}
}

View File

@@ -0,0 +1,62 @@
import * as encoding from "lib0/encoding";
import * as decoding from "lib0/decoding";
import type {AuthorizedScope} from "../../provider/src"
export enum AuthMessageType {
Token = 0,
PermissionDenied = 1,
Authenticated = 2,
}
export const writeAuthentication = (
encoder: encoding.Encoder,
auth: string,
) => {
encoding.writeVarUint(encoder, AuthMessageType.Token);
encoding.writeVarString(encoder, auth);
};
export const writePermissionDenied = (
encoder: encoding.Encoder,
reason: string,
) => {
encoding.writeVarUint(encoder, AuthMessageType.PermissionDenied);
encoding.writeVarString(encoder, reason);
};
export const writeAuthenticated = (
encoder: encoding.Encoder,
scope: AuthorizedScope,
) => {
encoding.writeVarUint(encoder, AuthMessageType.Authenticated);
encoding.writeVarString(encoder, scope);
};
export const writeTokenSyncRequest = (
encoder: encoding.Encoder,
) => {
encoding.writeVarUint(encoder, AuthMessageType.Token);
};
export const readAuthMessage = (
decoder: decoding.Decoder,
sendToken: () => void,
permissionDeniedHandler: (reason: string) => void,
authenticatedHandler: (scope: string) => void,
) => {
switch (decoding.readVarUint(decoder)) {
case AuthMessageType.Token: {
sendToken();
break;
}
case AuthMessageType.PermissionDenied: {
permissionDeniedHandler(decoding.readVarString(decoder));
break;
}
case AuthMessageType.Authenticated: {
authenticatedHandler(decoding.readVarString(decoder));
break;
}
default:
}
};

View File

@@ -0,0 +1,10 @@
export const awarenessStatesToArray = (
states: Map<number, Record<string, any>>,
) => {
return Array.from(states.entries()).map(([key, value]) => {
return {
clientId: key,
...value,
};
});
};

View File

@@ -0,0 +1,6 @@
export * from "./auth.ts";
export * from "./CloseEvents.ts";
export * from "./awarenessStatesToArray.ts";
export * from "./routingKey.ts";
export * from "./SkipFurtherHooksError.ts";
export * from "./types.ts";

View File

@@ -0,0 +1,11 @@
const SEPARATOR = '\0';
export function makeRoutingKey(documentName: string, sessionId: string): string {
return documentName + SEPARATOR + sessionId;
}
export function parseRoutingKey(key: string): { documentName: string; sessionId: string | null } {
const idx = key.indexOf(SEPARATOR);
if (idx === -1) return { documentName: key, sessionId: null };
return { documentName: key.substring(0, idx), sessionId: key.substring(idx + 1) };
}

View File

@@ -0,0 +1,10 @@
/**
* State of the WebSocket connection.
* https://developer.mozilla.org/de/docs/Web/API/WebSocket/readyState
*/
export enum WsReadyStates {
Connecting = 0,
Open = 1,
Closing = 2,
Closed = 3,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
# @hocuspocus/extension-database
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-database.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-database)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-database.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-database.svg)](https://www.npmjs.com/package/@hocuspocus/extension-database)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
A generic persistence base class for [Hocuspocus](https://github.com/ueberdosis/hocuspocus). Provide your own `fetch` and `store` functions to plug any database into the collaboration backend — Postgres, MySQL, MongoDB, a REST API, etc.
## Installation
```bash
npm install @hocuspocus/extension-database
```
## Usage
Plug in any storage backend by implementing two async functions:
```js
import { Server } from "@hocuspocus/server"
import { Database } from "@hocuspocus/extension-database"
const server = new Server({
extensions: [
new Database({
fetch: async ({ documentName }) => {
// return a Uint8Array of the stored Y.js update, or null for new documents
return await yourDb.findDocument(documentName)
},
store: async ({ documentName, state }) => {
// persist the binary Y.js state wherever you like
await yourDb.upsertDocument(documentName, state)
},
}),
],
})
server.listen()
```
`state` is a `Buffer` containing the encoded Y.js update. Store it as-is and return it unchanged from `fetch`.
For dedicated storage drivers, see [`@hocuspocus/extension-sqlite`](../extension-sqlite) or [`@hocuspocus/extension-s3`](../extension-s3), both of which extend this base class.
## Documentation
Persistence patterns and performance tips: [tiptap.dev/docs/hocuspocus/guides/persistence](https://tiptap.dev/docs/hocuspocus/guides/persistence).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,42 @@
{
"name": "@hocuspocus/extension-database",
"description": "a generic Hocuspocus persistence driver for the database",
"version": "4.7.0",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-database.cjs",
"module": "dist/hocuspocus-database.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-database.esm.js",
"require": "./dist/hocuspocus-database.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/server": "workspace:^"
},
"peerDependencies": {
"yjs": "^13.6.8"
},
"publishConfig": {
"access": "public"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
}
}

View File

@@ -0,0 +1,62 @@
import type {
Extension,
fetchPayload,
onChangePayload,
onLoadDocumentPayload,
onStoreDocumentPayload,
storePayload,
} from "@hocuspocus/server";
import * as Y from "yjs";
export interface DatabaseConfiguration {
/**
* Pass a Promise to retrieve updates from your database. The Promise should resolve to
* an array of items with Y.js-compatible binary data.
*/
fetch: (data: fetchPayload) => Promise<Uint8Array | null>;
/**
* Pass a function to store updates in your database.
*/
store: (data: storePayload) => Promise<void>;
}
export class Database implements Extension {
/**
* Default configuration
*/
configuration: DatabaseConfiguration = {
fetch: async () => null,
store: async () => {},
};
/**
* Constructor
*/
constructor(configuration: Partial<DatabaseConfiguration>) {
this.configuration = {
...this.configuration,
...configuration,
};
}
/**
* Get stored data from the database.
*/
async onLoadDocument(data: onLoadDocumentPayload): Promise<any> {
const update = await this.configuration.fetch(data);
if (update) {
Y.applyUpdate(data.document, update);
}
}
/**
* Store new updates in the database.
*/
async onStoreDocument(data: onStoreDocumentPayload) {
await this.configuration.store({
...data,
state: Buffer.from(Y.encodeStateAsUpdate(data.document)),
});
}
}

View File

@@ -0,0 +1 @@
export * from "./Database.ts";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
# @hocuspocus/extension-logger
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-logger.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-logger)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-logger.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-logger.svg)](https://www.npmjs.com/package/@hocuspocus/extension-logger)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Logs connection, document, and lifecycle events for a [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server to the console. Useful during development and debugging to see when clients connect, documents load and store, and changes come in.
## Installation
```bash
npm install @hocuspocus/extension-logger
```
## Usage
```js
import { Server } from "@hocuspocus/server"
import { Logger } from "@hocuspocus/extension-logger"
const server = new Server({
extensions: [new Logger()],
})
server.listen()
```
### Silence specific hooks
Pass booleans per hook to quiet down the ones you don't care about:
```js
new Logger({
onConnect: false,
onDisconnect: false,
onChange: false,
})
```
### Use a custom log function
Route output to your own logger (pino, winston, structured JSON, etc.):
```js
new Logger({
log: (...args) => myLogger.info({ msg: args.join(" ") }),
})
```
## Documentation
Full options reference: [tiptap.dev/docs/hocuspocus/server/extensions/logger](https://tiptap.dev/docs/hocuspocus/server/extensions/logger).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,41 @@
{
"name": "@hocuspocus/extension-logger",
"version": "4.7.0",
"description": "hocuspocus logging extension",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"logging",
"logger",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-logger.cjs",
"module": "dist/hocuspocus-logger.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-logger.esm.js",
"require": "./dist/hocuspocus-logger.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/server": "workspace:^"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,164 @@
import type {
Extension,
onChangePayload,
onConfigurePayload,
onConnectPayload,
onDestroyPayload,
onDisconnectPayload,
onLoadDocumentPayload,
onRequestPayload,
onStoreDocumentPayload,
onUpgradePayload,
} from "@hocuspocus/server";
export interface LoggerConfiguration {
/**
* Prepend all logging message with a string.
*
* @deprecated
*/
prefix: null | string;
/**
* Whether to log something for the `onLoadDocument` hook.
*/
onLoadDocument: boolean;
/**
* Whether to log something for the `onChange` hook.
*/
onChange: boolean;
/**
* Whether to log something for the `onStoreDocument` hook.
*/
onStoreDocument: boolean;
/**
* Whether to log something for the `onConnect` hook.
*/
onConnect: boolean;
/**
* Whether to log something for the `onDisconnect` hook.
*/
onDisconnect: boolean;
/**
* Whether to log something for the `onUpgrade` hook.
*/
onUpgrade: boolean;
/**
* Whether to log something for the `onRequest` hook.
*/
onRequest: boolean;
/**
* Whether to log something for the `onDestroy` hook.
*/
onDestroy: boolean;
/**
* Whether to log something for the `onConfigure` hook.
*/
onConfigure: boolean;
/**
* A log function, if none is provided output will go to console
*/
log: (...args: any[]) => void;
}
export class Logger implements Extension {
name: string | null = null;
configuration: LoggerConfiguration = {
prefix: null,
onLoadDocument: true,
onChange: true,
onStoreDocument: true,
onConnect: true,
onDisconnect: true,
onUpgrade: true,
onRequest: true,
onDestroy: true,
onConfigure: true,
log: console.log, // eslint-disable-line
};
/**
* Constructor
*/
constructor(configuration?: Partial<LoggerConfiguration>) {
this.configuration = {
...this.configuration,
...configuration,
};
}
async onConfigure(data: onConfigurePayload) {
this.name = data.instance.configuration.name;
if (!this.configuration.onConfigure) {
return;
}
if (this.configuration.prefix) {
console.warn(
"[hocuspocus warn] The Logger 'prefix' is deprecated. Pass a 'name' to the Hocuspocus configuration instead.",
);
}
}
async onLoadDocument(data: onLoadDocumentPayload) {
if (this.configuration.onLoadDocument) {
this.log(`Loaded document "${data.documentName}".`);
}
}
async onChange(data: onChangePayload) {
if (this.configuration.onChange) {
this.log(`Document "${data.documentName}" changed.`);
}
}
async onStoreDocument(data: onStoreDocumentPayload) {
if (this.configuration.onStoreDocument) {
this.log(`Store "${data.documentName}".`);
}
}
async onConnect(data: onConnectPayload) {
if (this.configuration.onConnect) {
this.log(`New connection to "${data.documentName}".`);
}
}
async onDisconnect(data: onDisconnectPayload) {
if (this.configuration.onDisconnect) {
this.log(`Connection to "${data.documentName}" closed.`);
}
}
async onUpgrade(data: onUpgradePayload) {
if (this.configuration.onUpgrade) {
this.log("Upgrading connection …");
}
}
async onRequest(data: onRequestPayload) {
if (this.configuration.onRequest) {
this.log(`Incoming HTTP Request to ${data.request.url}`);
}
}
async onDestroy(data: onDestroyPayload) {
if (this.configuration.onDestroy) {
this.log("Shut down.");
}
}
private log(message: string) {
const date = new Date().toISOString();
let meta = `${date}`;
if (this.name) {
meta = `${this.name} ${meta}`;
}
message = `[${meta}] ${message}`;
this.configuration.log(message);
}
}

View File

@@ -0,0 +1 @@
export * from "./Logger.ts";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
# @hocuspocus/extension-redis
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-redis.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-redis)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-redis.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-redis.svg)](https://www.npmjs.com/package/@hocuspocus/extension-redis)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Scale [Hocuspocus](https://github.com/ueberdosis/hocuspocus) horizontally across multiple server instances. Uses Redis pub/sub to broadcast document updates and awareness between servers, so clients connected to different instances stay in sync on the same document.
## Installation
```bash
npm install @hocuspocus/extension-redis
```
## Usage
Point every Hocuspocus instance at the same Redis:
```js
import { Server } from "@hocuspocus/server"
import { Redis } from "@hocuspocus/extension-redis"
const server = new Server({
port: 1234,
extensions: [
new Redis({
host: "127.0.0.1",
port: 6379,
}),
],
})
server.listen()
```
### Bring your own Redis client
If you already have an `ioredis` client (including Cluster), pass it in directly instead of host/port:
```js
import RedisClient from "ioredis"
new Redis({
redis: new RedisClient({ host: "127.0.0.1", port: 6379 }),
})
```
Redis handles real-time sync — you still need a persistence extension (e.g. [`@hocuspocus/extension-sqlite`](../extension-sqlite), [`@hocuspocus/extension-s3`](../extension-s3), or your own [`Database`](../extension-database)) to store documents long-term.
### Consistent reads on load
When a document is loaded on one instance while another instance already has it
open (and possibly modified in memory, not yet persisted), the load blocks until
the other instance has sent its current state — so a `DirectConnection` or a
freshly connected client observes the latest collaborative content rather than
just what was read from storage. If no other instance has the document open, the
load is not delayed.
The wait is bounded by `awaitInitialSyncTimeout` (ms, default `1000`). Set it to
`0` to disable the wait and restore the previous fire-and-forget behavior:
```js
new Redis({
host: "127.0.0.1",
port: 6379,
awaitInitialSyncTimeout: 1000,
})
```
## Documentation
Full scaling architecture and multi-instance deployment guide: [tiptap.dev/docs/hocuspocus/server/extensions/redis](https://tiptap.dev/docs/hocuspocus/server/extensions/redis).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,48 @@
{
"name": "@hocuspocus/extension-redis",
"version": "4.7.0",
"description": "Scale Hocuspocus horizontally with Redis",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"redis",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-redis.cjs",
"module": "dist/hocuspocus-redis.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-redis.esm.js",
"require": "./dist/hocuspocus-redis.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/common": "workspace:^",
"@hocuspocus/server": "workspace:^",
"@sesamecare-oss/redlock": "^1.4.0",
"ioredis": "~5.6.1",
"kleur": "^4.1.5"
},
"peerDependencies": {
"y-protocols": "^1.0.6",
"yjs": "^13.6.8"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,839 @@
import crypto from "node:crypto";
import { SkipFurtherHooksError } from "@hocuspocus/common";
import type {
afterLoadDocumentPayload,
afterStoreDocumentPayload,
afterUnloadDocumentPayload,
beforeBroadcastStatelessPayload,
beforeUnloadDocumentPayload,
Document,
Extension,
Hocuspocus,
onAwarenessUpdatePayload,
onChangePayload,
onConfigurePayload,
onStoreDocumentPayload,
RedisTransactionOrigin,
} from "@hocuspocus/server";
import {
IncomingMessage,
isTransactionOrigin,
MessageReceiver,
MessageType,
OutgoingMessage,
} from "@hocuspocus/server";
import {
ExecutionError,
type ExecutionResult,
type Lock,
Redlock,
} from "@sesamecare-oss/redlock";
import type {
Cluster,
ClusterNode,
ClusterOptions,
RedisOptions,
} from "ioredis";
import RedisClient from "ioredis";
import type { Awareness } from "y-protocols/awareness";
import { messageYjsSyncStep2, messageYjsUpdate } from "y-protocols/sync";
export type RedisInstance = RedisClient | Cluster;
export interface Configuration {
/**
* Redis port
*/
port: number;
/**
* Redis host
*/
host: string;
/**
* Redis Cluster
*/
nodes?: ClusterNode[];
/**
* Duplicate from an existed Redis instance
*/
redis?: RedisInstance;
/**
* Redis instance creator
*/
createClient?: () => RedisInstance;
/**
* Options passed directly to Redis constructor
*
* https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options
*/
options?: ClusterOptions | RedisOptions;
/**
* An unique instance name, required to filter messages in Redis.
* If none is provided an unique id is generated.
*/
identifier: string;
/**
* Namespace for Redis keys, if none is provided 'hocuspocus' is used
*/
prefix: string;
/**
* The maximum time for the Redis lock in ms (in case it can’t be released).
*/
lockTimeout: number;
/**
* A delay before onDisconnect is executed. This allows last minute updates'
* sync messages to be received by the subscription before it's closed.
*/
disconnectDelay: number;
/**
* The maximum time (in ms) `afterLoadDocument` waits for another instance to
* send its state when loading a document that is already open elsewhere.
*
* When a document is loaded on this instance while another instance already
* has it open (and possibly modified in memory, not yet persisted), we
* publish a SyncStep1 and block the load until the peer replies with its
* state (SyncStep2) — or this timeout elapses. This guarantees that callers
* (most importantly a `DirectConnection`) observe the latest collaborative
* state instead of just what was loaded from storage.
*
* If no other instance is subscribed to the document, the wait is skipped
* entirely, so a cold/lone load is not delayed.
*
* Set to `0` to disable the wait and restore the legacy fire-and-forget
* behavior.
*/
awaitInitialSyncTimeout: number;
}
/**
* Publishes for one document, accumulated over a single event loop turn.
*
* The two kinds are independent, not alternatives: a turn can carry a sync step,
* an awareness update, or both, and each is sent on its own.
*/
type PendingPublish = {
document: Document;
/** A SyncStep1 is pending. A flag, not a count: state vectors are idempotent. */
syncStepPending: boolean;
/** Clients whose awareness changed this turn; empty when none did. */
awarenessClients: Set<number>;
/** Set alongside `awarenessClients`; the awareness they belong to. */
awareness?: Awareness;
cancel: () => void;
};
export class Redis implements Extension {
/**
* Make sure to give that extension a higher priority, so
* the `onStoreDocument` hook is able to intercept the chain,
* before documents are stored to the database.
*/
priority = 1000;
configuration: Configuration = {
port: 6379,
host: "127.0.0.1",
prefix: "hocuspocus",
identifier: `host-${crypto.randomUUID()}`,
lockTimeout: 1000,
disconnectDelay: 1000,
awaitInitialSyncTimeout: 1000,
};
redisTransactionOrigin: RedisTransactionOrigin = {
source: "redis",
};
pub: RedisInstance;
sub: RedisInstance;
instance!: Hocuspocus;
redlock: Redlock;
locks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();
messagePrefix: Buffer;
/**
* Resolves once this instance is subscribed to its own reply channel.
* Awaited before a document announces itself, so a peer's reply can never
* arrive on a channel we are not listening to yet.
*/
private replySubscription: Promise<void>;
private pendingAfterStoreDocumentResolves = new Map<
string,
{ timeout: NodeJS.Timeout; resolve: () => void }
>();
/**
* Documents this instance has loaded and subscribed to, keyed by name.
*
* This mirrors `instance.documents` but is populated at the very start of
* `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in
* `instance.documents` (which only happens once loading, including this
* hook, has fully resolved). That early registration is what lets
* `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still
* blocking the load on it.
*/
private documents = new Map<string, Document>();
/**
* Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.
* Invoked by `handleIncomingMessage` as soon as a peer's state arrives.
*/
private pendingInitialSyncResolves = new Map<string, () => void>();
/**
* Publishes coalesced into the current event loop turn, keyed by document
* name. See `schedulePublish`.
*/
private pendingPublishes = new Map<string, PendingPublish>();
public constructor(configuration: Partial<Configuration>) {
this.configuration = {
...this.configuration,
...configuration,
};
// Create Redis instance
const { port, host, options, nodes, redis, createClient } =
this.configuration;
if (typeof createClient === "function") {
this.pub = createClient();
this.sub = createClient();
} else if (redis) {
this.pub = redis.duplicate();
this.sub = redis.duplicate();
} else if (nodes && nodes.length > 0) {
this.pub = new RedisClient.Cluster(nodes, options);
this.sub = new RedisClient.Cluster(nodes, options);
} else {
this.pub = new RedisClient(port, host, options ?? {});
this.sub = new RedisClient(port, host, options ?? {});
}
this.sub.on("messageBuffer", this.handleIncomingMessage);
this.redlock = new Redlock([this.pub], {
retryCount: 0,
});
const identifierBuffer = Buffer.from(
this.configuration.identifier,
"utf-8",
);
this.messagePrefix = Buffer.concat([
Buffer.from([identifierBuffer.length]),
identifierBuffer,
]);
// Subscribed once for the lifetime of the extension rather than per
// document: `handleIncomingMessage` resolves the document from the message
// payload, so a single channel per instance receives every reply addressed
// to us. Started here instead of in `onConfigure` because the server does
// not await that hook; `afterLoadDocument` awaits this promise, so no
// reply can be missed while the subscription is still pending.
this.replySubscription = new Promise<void>((resolve, reject) => {
this.sub.subscribe(
this.replyKey(this.configuration.identifier),
(error: any) => {
if (error) {
reject(error);
return;
}
resolve();
},
);
});
// The error is re-thrown to whoever awaits the promise in
// `afterLoadDocument`; this handler only keeps it from being reported as
// an unhandled rejection when no document is ever loaded.
this.replySubscription.catch(() => {});
}
async onConfigure({ instance }: onConfigurePayload) {
this.instance = instance;
}
private getKey(documentName: string) {
return `${this.configuration.prefix}:${documentName}`;
}
private pubKey(documentName: string) {
return this.getKey(documentName);
}
private subKey(documentName: string) {
return this.getKey(documentName);
}
private lockKey(documentName: string) {
return `${this.getKey(documentName)}:lock`;
}
/**
* The channel an instance receives replies to its own requests on.
*
* Separated from the prefix by `#` rather than `:`, so it can never collide
* with the document channel of a document that happens to be named
* `reply:<identifier>`.
*/
private replyKey(identifier: string) {
return `${this.configuration.prefix}#reply:${identifier}`;
}
private encodeMessage(message: Uint8Array) {
return Buffer.concat([this.messagePrefix, Buffer.from(message)]);
}
private decodeMessage(buffer: Buffer): [identifier: string, message: Buffer] {
const identifierLength = buffer[0];
const identifier = buffer.toString("utf-8", 1, identifierLength + 1);
return [identifier, buffer.slice(identifierLength + 1)];
}
/**
* Once a document is loaded, subscribe to the channel in Redis.
*/
public async afterLoadDocument({
documentName,
document,
}: afterLoadDocumentPayload) {
// Track the document locally right away so `handleIncomingMessage` can
// apply inbound messages to it even before Hocuspocus has finished
// loading it (it only lands in `instance.documents` after this hook
// resolves). Without this, the SyncStep2 reply we request below would be
// dropped while we wait for it.
this.documents.set(documentName, document);
try {
// Replies to the SyncStep1 we are about to publish are addressed to
// this instance's reply channel, so that subscription has to be live
// first.
await this.replySubscription;
await new Promise<void>((resolve, reject) => {
// On document creation the node will connect to pub and sub channels
// for the document.
this.sub.subscribe(this.subKey(documentName), (error: any) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await this.syncInitialStateFromPeers(documentName, document);
} catch (error) {
// Loading failed: stop tracking the document so future messages aren't
// applied to a doc that never finished loading, release any pending
// wait, and best-effort unsubscribe (afterUnloadDocument does not run
// for a load that never completed).
this.documents.delete(documentName);
this.pendingInitialSyncResolves.delete(documentName);
this.sub.unsubscribe(this.subKey(documentName), () => {});
throw error;
}
}
/**
* Announce our state to other instances and — when another instance already
* has this document open — block until it has sent us its state (or the
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
*/
private async syncInitialStateFromPeers(
documentName: string,
document: Document,
) {
const timeout = this.configuration.awaitInitialSyncTimeout;
const waitForPeers =
timeout > 0 && (await this.hasOtherSubscribers(documentName));
// Announce our state and request awareness. Awaited so a Redis publish
// failure surfaces as a load error instead of an unhandled rejection.
await Promise.all([
this.publishFirstSyncStep(documentName, document),
this.requestAwarenessFromOtherInstances(documentName),
]);
// Skip the wait when it's disabled or nobody else has the document open:
// there is no peer to sync from, so blocking would only add latency.
if (!waitForPeers) {
return;
}
// A peer has the document open. Block until it replies with its state
// (a SyncStep2/Update applied via handleIncomingMessage resolves this) or
// the timeout elapses. The reply cannot arrive before the SyncStep1 we
// just published is delivered, so registering the resolver now — right
// after the awaited publish, before yielding to the event loop — cannot
// miss it.
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.pendingInitialSyncResolves.delete(documentName);
resolve();
}, timeout);
this.pendingInitialSyncResolves.set(documentName, () => {
clearTimeout(timer);
this.pendingInitialSyncResolves.delete(documentName);
resolve();
});
});
}
/**
* Whether another instance is currently subscribed to a document's channel.
* We are subscribed ourselves, so a subscriber count greater than one means
* at least one peer has the document open.
*/
private async hasOtherSubscribers(documentName: string): Promise<boolean> {
try {
const result = (await this.pub.pubsub(
"NUMSUB",
this.subKey(documentName),
)) as [string, number | string];
return Number(result?.[1] ?? 0) > 1;
} catch {
// NUMSUB may be unavailable in some setups (e.g. certain clusters).
// Assume peers might exist so correctness is preserved; the timeout
// still bounds the wait.
return true;
}
}
/**
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
* applied.
*/
private resolveInitialSync(documentName: string) {
this.pendingInitialSyncResolves.get(documentName)?.();
}
/**
* Peek whether a message carries another instance's document state
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
* *requests* state, so it must not release an initial-sync wait.
*
* Carrying state is necessary but not sufficient to release that wait: the
* state also has to have been computed for *our* state vector, which is what
* arriving on our reply channel means. See `handleIncomingMessage`.
*/
private messageCarriesPeerState(message: IncomingMessage): boolean {
const { pos } = message.decoder;
try {
const type = message.readVarUint();
if (type !== MessageType.Sync && type !== MessageType.SyncReply) {
return false;
}
const syncType = message.readVarUint();
return syncType === messageYjsSyncStep2 || syncType === messageYjsUpdate;
} catch {
return false;
} finally {
message.decoder.pos = pos;
}
}
/**
* Coalesce this document's Redis publishes into the current event loop turn.
*
* Both publishers are per-change and neither is covered by the server's
* `flushDelay` batching: `onChange` runs synchronously by design (it also
* drives the store hooks) and `onAwarenessUpdate` is dispatched from a raw
* awareness listener rather than from `Document`'s batched broadcast. Left
* uncoalesced, a document with many writing clients publishes one SyncStep1
* per change and every peer answers each one with a full
* `encodeStateAsUpdate` diff — which is O(document), so this costs far more
* per change than the WebSocket fan-out ever did.
*
* Collapsing is lossless. A SyncStep1 carries the document's *current* state
* vector, so the last one of a turn is a superset of the ones it replaces
* and a peer's reply to it covers every skipped change. Awareness collapses
* to the latest state of each changed client, exactly as `Document` does for
* its own fan-out.
*/
private schedulePublish(
documentName: string,
document: Document,
): PendingPublish {
const existing = this.pendingPublishes.get(documentName);
if (existing) {
if (existing.document === document) {
return existing;
}
// The document was reloaded under the same name. `afterUnloadDocument`
// returns early on a fast reconnect, so it cannot be relied on to have
// cleared this. Whatever is buffered describes the previous instance —
// its state vector and its awareness — so drop it rather than publish
// it on behalf of the new one.
existing.cancel();
this.pendingPublishes.delete(documentName);
}
const handle = setImmediate(() => {
this.flushPublish(documentName).catch((error) => {
console.error(
`Redis: failed to publish for document "${documentName}"`,
error,
);
});
});
const pending: PendingPublish = {
document,
syncStepPending: false,
awarenessClients: new Set<number>(),
cancel: () => clearImmediate(handle),
};
this.pendingPublishes.set(documentName, pending);
return pending;
}
private cancelPendingPublish(documentName: string) {
const pending = this.pendingPublishes.get(documentName);
if (pending) {
pending.cancel();
this.pendingPublishes.delete(documentName);
}
}
/**
* Send everything accumulated for this document in the current turn.
*/
private async flushPublish(documentName: string) {
const pending = this.pendingPublishes.get(documentName);
if (!pending) {
return;
}
this.pendingPublishes.delete(documentName);
// Both are issued before anything is awaited, so a failing sync step
// cannot swallow the awareness update — the entry is already dropped from
// the map at this point, and those clients would never be retried.
const publishes: Array<Promise<unknown>> = [];
if (pending.syncStepPending) {
publishes.push(this.publishFirstSyncStep(documentName, pending.document));
}
// Same guard as the uncoalesced path: publishing awareness for a document
// nobody is connected to throws (see hocuspocus#1027).
if (
pending.awarenessClients.size > 0 &&
pending.awareness &&
pending.document.connections.size > 0
) {
const message = new OutgoingMessage(
documentName,
).createAwarenessUpdateMessage(
pending.awareness,
Array.from(pending.awarenessClients),
);
publishes.push(
this.pub.publish(
this.pubKey(documentName),
this.encodeMessage(message.toUint8Array()),
),
);
}
await Promise.all(publishes);
}
/**
* Publish the first sync step through Redis.
*/
private async publishFirstSyncStep(documentName: string, document: Document) {
const syncMessage = new OutgoingMessage(documentName)
.createSyncMessage()
.writeFirstSyncStepFor(document);
return this.pub.publish(
this.pubKey(documentName),
this.encodeMessage(syncMessage.toUint8Array()),
);
}
/**
* Let’s ask Redis who is connected already.
*/
private async requestAwarenessFromOtherInstances(documentName: string) {
const awarenessMessage = new OutgoingMessage(
documentName,
).writeQueryAwareness();
return this.pub.publish(
this.pubKey(documentName),
this.encodeMessage(awarenessMessage.toUint8Array()),
);
}
/**
* Before the document is stored, make sure to set a lock in Redis.
* That’s meant to avoid conflicts with other instances trying to store the document.
*/
async onStoreDocument({ documentName }: onStoreDocumentPayload) {
// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,
// to avoid conflict with other instances storing the same document.
const resource = this.lockKey(documentName);
const ttl = this.configuration.lockTimeout;
try {
const lock = await this.redlock.acquire([resource], ttl);
const oldLock = this.locks.get(resource);
if (oldLock?.release) {
await oldLock.release;
}
this.locks.set(resource, { lock });
} catch (error: any) {
//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349
if (
error instanceof ExecutionError &&
error.message ===
"The operation was unable to achieve a quorum during its retry window."
) {
// Expected behavior: Could not acquire lock, another instance locked it already.
// Skip further hooks and retry — the data is safe on the other instance.
throw new SkipFurtherHooksError(
"Another instance is already storing this document",
);
}
//unexpected error
console.error("unexpected error:", error);
throw error;
}
}
/**
* Release the Redis lock, so other instances can store documents.
*/
async afterStoreDocument({
documentName,
lastTransactionOrigin,
}: afterStoreDocumentPayload) {
const lockKey = this.lockKey(documentName);
const lock = this.locks.get(lockKey);
if (lock) {
try {
// Always try to unlock and clean up the lock
lock.release = lock.lock.release();
await lock.release;
} catch {
// Lock will expire on its own after timeout
} finally {
this.locks.delete(lockKey);
}
}
// if the change was initiated by a directConnection (or otherwise local source), we need to delay this hook to make sure sync can finish first.
// for provider connections, this usually happens in the onDisconnect hook
if (
isTransactionOrigin(lastTransactionOrigin) &&
lastTransactionOrigin.source === "local"
) {
const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve();
this.pendingAfterStoreDocumentResolves.delete(documentName);
}
let resolveFunction: () => void = () => {};
const delayedPromise = new Promise<void>((resolve) => {
resolveFunction = resolve;
});
const timeout = setTimeout(() => {
this.pendingAfterStoreDocumentResolves.delete(documentName);
resolveFunction();
}, this.configuration.disconnectDelay);
this.pendingAfterStoreDocumentResolves.set(documentName, {
timeout,
resolve: resolveFunction,
});
await delayedPromise;
}
}
/**
* Handle awareness update messages received directly by this Hocuspocus instance.
*/
async onAwarenessUpdate({
documentName,
awareness,
added,
updated,
removed,
document,
}: onAwarenessUpdatePayload) {
// Do not publish if there is no connection: it fixes this issue: "https://github.com/ueberdosis/hocuspocus/issues/1027"
const connections = document?.connections.size || 0;
if (connections === 0) {
return; // avoids exception
}
const pending = this.schedulePublish(documentName, document);
pending.awareness = awareness;
for (const clientId of added.concat(updated, removed)) {
pending.awarenessClients.add(clientId);
}
}
/**
* Handle incoming messages published on subscribed document channels.
* Note that this will also include messages from ourselves as it is not possible
* in Redis to filter these.
*/
private handleIncomingMessage = async (channel: Buffer, data: Buffer) => {
const [identifier, messageBuffer] = this.decodeMessage(data);
if (identifier === this.configuration.identifier) {
return;
}
const message = new IncomingMessage(messageBuffer);
const documentName = message.readVarString();
message.writeVarString(documentName);
// Prefer the extension-local map: it also contains documents that are
// still loading (not yet in `instance.documents`), which is exactly when
// a blocking `afterLoadDocument` needs to receive the peer's reply.
const document =
this.documents.get(documentName) ??
this.instance.documents.get(documentName);
if (!document) {
return;
}
const carriesPeerState = this.messageCarriesPeerState(message);
// State that reached us on our own reply channel was computed for our
// state vector; state overheard anywhere else was computed for somebody
// else and says nothing about whether we have caught up.
const addressedToUs =
channel.toString("utf-8") ===
this.replyKey(this.configuration.identifier);
const receiver = new MessageReceiver(message, this.redisTransactionOrigin);
await receiver.apply(document, undefined, (reply) => {
// Replies go to the requester alone, never to the document channel.
// A SyncStep2 is `encodeStateAsUpdate(document, requesterStateVector)`:
// the structs that one requester is missing plus the *entire* delete
// set. An instance whose state is behind the requester's applies those
// deletes without receiving the structs that replaced the deleted
// content, leaving it with a document that never existed — which it
// then broadcasts to its own clients and can persist (#1151).
return this.pub.publish(
this.replyKey(identifier),
this.encodeMessage(reply),
);
});
// A peer answered our SyncStep1 with its state — the document has now
// caught up, so any blocking initial-sync wait can be released.
if (carriesPeerState && addressedToUs) {
this.resolveInitialSync(documentName);
}
};
/**
* if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
*/
public async onChange(data: onChangePayload): Promise<any> {
if (
isTransactionOrigin(data.transactionOrigin) &&
data.transactionOrigin.source === "redis"
) {
return;
}
this.schedulePublish(data.documentName, data.document).syncStepPending =
true;
}
/**
* Delay unloading to allow syncs to finish
*/
async beforeUnloadDocument(data: beforeUnloadDocumentPayload) {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, this.configuration.disconnectDelay);
});
}
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
if (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)
this.resolveInitialSync(data.documentName);
this.documents.delete(data.documentName);
// Nothing should be pending: the coalescing window is a single event loop
// turn and `beforeUnloadDocument` already waits `disconnectDelay` to let
// syncs finish. This is a safety net so a timer cannot fire against a
// document that is on its way out.
this.cancelPendingPublish(data.documentName);
this.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {
if (error) {
console.error(error);
}
});
}
async beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {
const message = new OutgoingMessage(
data.documentName,
).writeBroadcastStateless(data.payload);
return this.pub.publish(
this.pubKey(data.documentName),
this.encodeMessage(message.toUint8Array()),
);
}
/**
* Kill the Redlock connection immediately.
*/
async onDestroy() {
// Cancelled rather than flushed, because flushing could not achieve
// anything: a pending publish is a SyncStep1, i.e. a state vector, which
// only *asks* peers to reply. Our own changes reach them one round trip
// later, when we answer the SyncStep1 they send back — and the subscriber
// is disconnected a few lines below, so that answer would never come.
// Durability on shutdown comes from the fact that we flush per setImmediate,
// which runs before the delay in beforeUnloadDocument.
for (const documentName of Array.from(this.pendingPublishes.keys())) {
this.cancelPendingPublish(documentName);
}
await this.redlock.quit();
this.pub.disconnect(false);
this.sub.disconnect(false);
}
}

View File

@@ -0,0 +1 @@
export * from "./Redis.ts";

View File

@@ -0,0 +1,337 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [4.7.0](https://github.com/ueberdosis/hocuspocus/compare/v4.6.0...v4.7.0) (2026-09-09)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.6.0](https://github.com/ueberdosis/hocuspocus/compare/v4.5.0...v4.6.0) (2026-08-10)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.5.0](https://github.com/ueberdosis/hocuspocus/compare/v4.4.0...v4.5.0) (2026-08-04)
### Bug Fixes
* update packages via audit --fix ([1dc9ca0](https://github.com/ueberdosis/hocuspocus/commit/1dc9ca0ff35f1033136473d134cee8cb6b336281))
# [4.4.0](https://github.com/ueberdosis/hocuspocus/compare/v4.3.0...v4.4.0) (2026-07-13)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.3.0](https://github.com/ueberdosis/hocuspocus/compare/v4.2.0...v4.3.0) (2026-06-18)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.2.0](https://github.com/ueberdosis/hocuspocus/compare/v4.1.2...v4.2.0) (2026-06-12)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [4.1.2](https://github.com/ueberdosis/hocuspocus/compare/v4.1.1...v4.1.2) (2026-06-11)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [4.1.1](https://github.com/ueberdosis/hocuspocus/compare/v4.1.0...v4.1.1) (2026-06-10)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.1.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0...v4.1.0) (2026-05-20)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.7...v4.0.0) (2026-04-23)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.7](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.6...v4.0.0-rc.7) (2026-04-22)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.6](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.5...v4.0.0-rc.6) (2026-04-20)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-04-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-04-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-04-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.2](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.1...v4.0.0-rc.2) (2026-04-08)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.0...v4.0.0-rc.1) (2026-03-30)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0-rc.0) (2026-03-16)
### Features
* migrate to pnpm ([#1061](https://github.com/ueberdosis/hocuspocus/issues/1061)) ([dcaab57](https://github.com/ueberdosis/hocuspocus/commit/dcaab571bad694e48efd41bbb1d675855a7e2055))
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-03-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-03-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-03-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.2](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.1...v4.0.0-rc.2) (2026-03-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.0...v4.0.0-rc.1) (2026-03-16)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [4.0.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0-rc.0) (2026-02-18)
### Features
* migrate to pnpm ([#1061](https://github.com/ueberdosis/hocuspocus/issues/1061)) ([dcaab57](https://github.com/ueberdosis/hocuspocus/commit/dcaab571bad694e48efd41bbb1d675855a7e2055))
## [3.4.6-rc.2](https://github.com/ueberdosis/hocuspocus/compare/v3.4.6-rc.1...v3.4.6-rc.2) (2026-02-03)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.6-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v3.4.6-rc.0...v3.4.6-rc.1) (2026-02-03)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.6-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.5-rc.0...v3.4.6-rc.0) (2026-02-03)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.5-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v3.4.5-rc.0) (2026-02-03)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.4](https://github.com/ueberdosis/hocuspocus/compare/v3.4.3...v3.4.4) (2026-01-25)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.3](https://github.com/ueberdosis/hocuspocus/compare/v3.4.2...v3.4.3) (2025-12-15)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.2](https://github.com/ueberdosis/hocuspocus/compare/v3.4.1...v3.4.2) (2025-12-15)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.4.1](https://github.com/ueberdosis/hocuspocus/compare/v3.4.0...v3.4.1) (2025-12-09)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [3.4.0](https://github.com/ueberdosis/hocuspocus/compare/v3.3.2...v3.4.0) (2025-10-24)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.3.2](https://github.com/ueberdosis/hocuspocus/compare/v3.3.1...v3.3.2) (2025-10-23)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.3.1](https://github.com/ueberdosis/hocuspocus/compare/v3.3.0...v3.3.1) (2025-10-22)
**Note:** Version bump only for package @hocuspocus/extension-s3
# [3.3.0](https://github.com/ueberdosis/hocuspocus/compare/v3.2.6...v3.3.0) (2025-10-22)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.2.6](https://github.com/ueberdosis/hocuspocus/compare/v3.2.5...v3.2.6) (2025-10-17)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.2.5](https://github.com/ueberdosis/hocuspocus/compare/v3.2.4...v3.2.5) (2025-10-07)
**Note:** Version bump only for package @hocuspocus/extension-s3
## [3.2.4](https://github.com/ueberdosis/hocuspocus/compare/v3.2.3...v3.2.4) (2025-09-30)
**Note:** Version bump only for package @hocuspocus/extension-s3
# Changelog
## [3.2.3] - 2024-08-10
### Added
- Initial release of @hocuspocus/extension-s3
- Support for Amazon S3 and S3-compatible storage services (MinIO, DigitalOcean Spaces, etc.)
- Configurable bucket, region, and key prefix
- Support for custom S3 clients
- IAM role support for AWS environments
- Proper error handling for missing documents

View File

@@ -0,0 +1,171 @@
# @hocuspocus/extension-s3
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-s3.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-s3)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-s3.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-s3.svg)](https://www.npmjs.com/package/@hocuspocus/extension-s3)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
A S3-compatible persistence driver for [Hocuspocus](https://github.com/ueberdosis/hocuspocus). Stores Y.js documents in Amazon S3 or S3-compatible storage (MinIO, DigitalOcean Spaces, Cloudflare R2, Backblaze B2, etc.).
## Installation
```bash
npm install @hocuspocus/extension-s3
```
## Quick Start
### Basic AWS S3 Configuration
```javascript
import { Server } from '@hocuspocus/server'
import { S3 } from '@hocuspocus/extension-s3'
const server = new Server({
extensions: [
new S3({
bucket: 'my-documents-bucket',
region: 'us-east-1',
credentials: {
accessKeyId: 'your-access-key',
secretAccessKey: 'your-secret-key'
}
}),
],
})
server.listen()
```
### MinIO Configuration
For local development with MinIO, ensure you set `forcePathStyle: true`:
```javascript
const server = new Server({
extensions: [
new S3({
bucket: 'hocuspocus-documents',
endpoint: 'http://localhost:9000',
forcePathStyle: true,
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin'
}
}),
],
})
```
## Configuration Options
- **bucket** (required): S3 bucket name where documents will be stored
- **region**: AWS region (default: 'us-east-1')
- **prefix**: Key prefix for documents (default: 'hocuspocus-documents/')
- **credentials**: AWS credentials object with accessKeyId and secretAccessKey
- **endpoint**: S3 endpoint URL (for S3-compatible services)
- **forcePathStyle**: Use path-style URLs (required for MinIO, default: false)
- **s3Client**: Custom S3Client instance
## Document Storage
Documents are stored as binary files in S3 with the naming convention:
`{prefix}{documentName}.bin`
## Scaling with Redis
For horizontal scaling, combine S3 with the Redis extension. Redis handles real-time synchronization while S3 provides persistent storage.
```javascript
import { Server } from '@hocuspocus/server'
import { Logger } from '@hocuspocus/extension-logger'
import { Redis } from '@hocuspocus/extension-redis'
import { S3 } from '@hocuspocus/extension-s3'
// Server 1
const server1 = new Server({
name: "server-1",
port: 8001,
extensions: [
new Logger(),
new Redis({
host: "127.0.0.1",
port: 6379,
}),
new S3({
bucket: 'hocuspocus-documents',
endpoint: 'http://localhost:9000', // MinIO
forcePathStyle: true,
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin'
}
}),
],
})
// Server 2 - same configuration with different name/port
const server2 = new Server({
name: "server-2",
port: 8002,
extensions: [/* same extensions */],
})
server1.listen()
server2.listen()
```
## CLI Usage
You can also use the S3 extension with the Hocuspocus CLI:
```bash
# Basic usage
hocuspocus --s3 --s3-bucket my-documents
# MinIO setup (forcePathStyle automatically enabled)
hocuspocus --s3 --s3-bucket hocuspocus-documents --s3-endpoint http://localhost:9000
```
## Development
### Quick Start
For local development with MinIO (S3-compatible storage):
```bash
# Set up development environment
npm run dev:setup
# Test S3 configuration
npm run dev:test-s3
# Run S3 playground examples
npm run playground:s3
```
## Complete Documentation
For detailed documentation, examples, best practices, and troubleshooting, see:
[📚 S3 Extension Documentation](https://tiptap.dev/docs/hocuspocus/server/extensions/s3)
## IAM Permissions
Your AWS credentials or IAM role needs the following permissions:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:HeadObject"],
"Resource": "arn:aws:s3:::your-bucket-name/*"
},
{
"Effect": "Allow",
"Action": ["s3:HeadBucket"],
"Resource": "arn:aws:s3:::your-bucket-name"
}
]
}
```

View File

@@ -0,0 +1,42 @@
{
"name": "@hocuspocus/extension-s3",
"description": "a S3-compatible persistence driver for Hocuspocus",
"version": "4.7.0",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"yjs",
"s3",
"persistence"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-s3.cjs",
"module": "dist/hocuspocus-s3.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-s3.esm.js",
"require": "./dist/hocuspocus-s3.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@aws-sdk/client-s3": "^3.990.0",
"@hocuspocus/extension-database": "workspace:^",
"kleur": "^4.1.5"
},
"publishConfig": {
"access": "public"
},
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
}
}

View File

@@ -0,0 +1,197 @@
import type { DatabaseConfiguration } from "@hocuspocus/extension-database";
import { Database } from "@hocuspocus/extension-database";
import {
S3Client,
GetObjectCommand,
PutObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
import kleur from "kleur";
export interface S3Configuration extends DatabaseConfiguration {
/**
* AWS S3 region
*/
region?: string;
/**
* S3 bucket name
*/
bucket: string;
/**
* S3 key prefix for documents (optional)
*/
prefix?: string;
/**
* AWS credentials
*/
credentials?: {
accessKeyId: string;
secretAccessKey: string;
};
/**
* S3 endpoint URL (for S3-compatible services like MinIO)
*/
endpoint?: string;
/**
* Force path style URLs (required for MinIO)
*/
forcePathStyle?: boolean;
/**
* Custom S3 client
*/
s3Client?: S3Client;
}
export class S3 extends Database {
private s3Client?: S3Client;
configuration: S3Configuration = {
region: "us-east-1",
bucket: "",
prefix: "hocuspocus-documents/",
forcePathStyle: false,
fetch: async ({ documentName }) => {
const key = this.getObjectKey(documentName);
try {
const command = new GetObjectCommand({
Bucket: this.configuration.bucket,
Key: key,
});
const response = await this.s3Client!.send(command);
if (response.Body) {
// Convert stream to Uint8Array
const chunks: Uint8Array[] = [];
const reader = response.Body.transformToWebStream().getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Combine all chunks into a single Uint8Array
const totalLength = chunks.reduce(
(acc, chunk) => acc + chunk.length,
0,
);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
return null;
} catch (error: any) {
if (
error.name === "NoSuchKey" ||
error.$metadata?.httpStatusCode === 404
) {
// Document doesn't exist yet, return null
return null;
}
throw error;
}
},
store: async ({ documentName, state }) => {
const key = this.getObjectKey(documentName);
const command = new PutObjectCommand({
Bucket: this.configuration.bucket,
Key: key,
Body: state,
ContentType: "application/octet-stream",
});
await this.s3Client!.send(command);
},
};
constructor(configuration: Partial<S3Configuration>) {
super({});
this.configuration = {
...this.configuration,
...configuration,
};
// Validate required configuration
if (!this.configuration.bucket) {
throw new Error("S3 bucket name is required");
}
}
private getObjectKey(documentName: string): string {
const prefix = this.configuration.prefix || "";
return `${prefix}${documentName}.bin`;
}
async onConfigure() {
// Use custom S3 client if provided, otherwise create one
if (this.configuration.s3Client) {
this.s3Client = this.configuration.s3Client;
} else {
const clientConfig: any = {
region: this.configuration.region,
};
if (this.configuration.credentials) {
clientConfig.credentials = this.configuration.credentials;
}
if (this.configuration.endpoint) {
clientConfig.endpoint = this.configuration.endpoint;
clientConfig.forcePathStyle = this.configuration.forcePathStyle;
}
this.s3Client = new S3Client(clientConfig);
}
// Test S3 connection by checking if bucket exists
try {
const command = new HeadObjectCommand({
Bucket: this.configuration.bucket,
Key: "test-connection", // This will likely return 404, but that's fine
});
await this.s3Client.send(command);
} catch (error: any) {
// 404 is expected for the test key, any other error indicates connection issues
if (error.$metadata?.httpStatusCode !== 404) {
// Don't show credential errors as connection failures in development
if (error.message?.includes("Could not load credentials")) {
console.warn(` ${kleur.yellow("S3 warning:")} ${error.message}`);
console.warn(
` ${kleur.yellow("Note:")} Ensure AWS credentials are properly configured for production use`,
);
} else {
console.error(
` ${kleur.red("S3 connection failed:")} ${error.message}`,
);
}
}
}
}
async onListen() {
const endpoint =
this.configuration.endpoint ||
`https://s3.${this.configuration.region}.amazonaws.com`;
console.log(
` ${kleur.green("S3 extension configured:")} bucket=${this.configuration.bucket}, endpoint=${endpoint}`,
);
if (this.configuration.prefix) {
console.log(
` ${kleur.blue("S3 key prefix:")} ${this.configuration.prefix}`,
);
}
}
}

View File

@@ -0,0 +1 @@
export * from "./S3.ts";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
# @hocuspocus/extension-sqlite
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-sqlite.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-sqlite)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-sqlite.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-sqlite.svg)](https://www.npmjs.com/package/@hocuspocus/extension-sqlite)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Persist [Hocuspocus](https://github.com/ueberdosis/hocuspocus) documents to a local SQLite database. Zero-config storage for development, self-hosted single-instance deployments, and small production setups. Uses [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3).
## Installation
```bash
npm install @hocuspocus/extension-sqlite
```
## Usage
```js
import { Server } from "@hocuspocus/server"
import { SQLite } from "@hocuspocus/extension-sqlite"
const server = new Server({
extensions: [
new SQLite({ database: "db.sqlite" }),
],
})
server.listen()
```
`database` accepts:
- a filename (persisted to disk, e.g. `"db.sqlite"`)
- `":memory:"` for an in-memory database
- `""` for an anonymous on-disk database
Omit the option entirely to default to `":memory:"`.
## Troubleshooting
### `Could not locate the bindings file`
`better-sqlite3` ships prebuilt native addons, but they don't always cover the newest Node.js release on day one. Rebuild from source in your server project:
```bash
npm rebuild better-sqlite3 --build-from-source
```
Needs a C++ toolchain (Xcode Command Line Tools on macOS, `build-essential` on Debian/Ubuntu, Visual Studio Build Tools on Windows).
## Documentation
Schema, custom `fetch`/`store`, and migration guides: [tiptap.dev/docs/hocuspocus/server/extensions/sqlite](https://tiptap.dev/docs/hocuspocus/server/extensions/sqlite).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,44 @@
{
"name": "@hocuspocus/extension-sqlite",
"description": "a generic Hocuspocus persistence driver for the sqlite",
"version": "4.7.0",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-sqlite.cjs",
"module": "dist/hocuspocus-sqlite.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-sqlite.esm.js",
"require": "./dist/hocuspocus-sqlite.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/extension-database": "workspace:^",
"better-sqlite3": "^12.6.2",
"kleur": "^4.1.5"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13"
},
"publishConfig": {
"access": "public"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
}
}

View File

@@ -0,0 +1,81 @@
import type { DatabaseConfiguration } from "@hocuspocus/extension-database";
import { Database } from "@hocuspocus/extension-database";
import BetterSqlite3 from "better-sqlite3";
import kleur from "kleur";
export const schema = `CREATE TABLE IF NOT EXISTS "documents" (
"name" varchar(255) NOT NULL,
"data" blob NOT NULL,
UNIQUE(name)
)`;
export const selectQuery = `
SELECT data FROM "documents" WHERE name = $name ORDER BY rowid DESC
`;
export const upsertQuery = `
INSERT INTO "documents" ("name", "data") VALUES ($name, $data)
ON CONFLICT(name) DO UPDATE SET data = $data
`;
const SQLITE_INMEMORY = ":memory:";
export interface SQLiteConfiguration extends DatabaseConfiguration {
/**
* Valid values are filenames, ":memory:" for an anonymous in-memory database and an empty
* string for an anonymous disk-based database. Anonymous databases are not persisted and
* when closing the database handle, their contents are lost.
*
* https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#new-daboratabasepath-options
*/
database: string;
/**
* The database schema to create.
*/
schema: string;
}
export class SQLite extends Database {
db?: BetterSqlite3.Database;
configuration: SQLiteConfiguration = {
database: SQLITE_INMEMORY,
schema,
fetch: async ({ documentName }) => {
const row = this.db
?.prepare(selectQuery)
.get({ name: documentName }) as { data: Buffer } | undefined;
return row?.data ?? null;
},
store: async ({ documentName, state }) => {
this.db?.prepare(upsertQuery).run({
name: documentName,
data: state,
});
},
};
constructor(configuration?: Partial<SQLiteConfiguration>) {
super({});
this.configuration = {
...this.configuration,
...configuration,
};
}
async onConfigure() {
this.db = new BetterSqlite3(this.configuration.database);
this.db.exec(this.configuration.schema);
}
async onListen() {
if (this.configuration.database === SQLITE_INMEMORY) {
console.warn(
` ${kleur.yellow("The SQLite extension is configured as an in-memory database. All changes will be lost on restart!")}`,
);
console.log();
}
}
}

View File

@@ -0,0 +1 @@
export * from "./SQLite.ts";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
# @hocuspocus/extension-throttle
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-throttle.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-throttle.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-throttle.svg)](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Rate-limit and throttle connections to a [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server by IP. Protects your collaboration backend from abuse, accidental reconnect loops, and misbehaving clients.
## Installation
```bash
npm install @hocuspocus/extension-throttle
```
## Usage
With default thresholds (15 connection attempts per 60 seconds, 5 minute ban):
```js
import { Server } from "@hocuspocus/server"
import { Throttle } from "@hocuspocus/extension-throttle"
const server = new Server({
extensions: [new Throttle()],
})
server.listen()
```
### Tune the limits
```js
new Throttle({
throttle: 30, // allowed connection attempts per window
consideredSeconds: 60, // window length in seconds
banTime: 5, // ban duration in minutes
})
```
Pass `throttle: false` (or `null`) to effectively disable the limiter without removing the extension.
## Documentation
Full options reference: [tiptap.dev/docs/hocuspocus/server/extensions/throttle](https://tiptap.dev/docs/hocuspocus/server/extensions/throttle).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,40 @@
{
"name": "@hocuspocus/extension-throttle",
"version": "4.7.0",
"description": "hocuspocus throttle extension",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"throttle",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-throttle.cjs",
"module": "dist/hocuspocus-throttle.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-throttle.esm.js",
"require": "./dist/hocuspocus-throttle.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/server": "workspace:^"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,126 @@
import type { Extension, onConnectPayload } from "@hocuspocus/server";
export interface ThrottleConfiguration {
throttle: number | null | false; // how many requests within `consideredSeconds` until we're rejecting requests (setting this to 15 means the 16th request will be rejected)
consideredSeconds: number; // how many seconds to consider (default is last 60 seconds from the current connection attempt)
banTime: number; // for how long to ban after receiving too many requests (in minutes!)
cleanupInterval: number; // how often to clean up the records of IPs (this won't delete ips that are still blocked or recent enough by `consideredSeconds`)
}
export class Throttle implements Extension {
configuration: ThrottleConfiguration = {
throttle: 15,
banTime: 5,
consideredSeconds: 60,
cleanupInterval: 90,
};
connectionsByIp: Map<string, Array<number>> = new Map();
bannedIps: Map<string, number> = new Map();
cleanupInterval?: NodeJS.Timeout;
/**
* Constructor
*/
constructor(configuration?: Partial<ThrottleConfiguration>) {
this.configuration = {
...this.configuration,
...configuration,
};
this.cleanupInterval = setInterval(
this.clearMaps.bind(this),
this.configuration.cleanupInterval * 1000,
);
}
onDestroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
return Promise.resolve();
}
public clearMaps() {
this.connectionsByIp.forEach((value, key) => {
const filteredValue = value.filter(
(timestamp) =>
timestamp + this.configuration.consideredSeconds * 1000 > Date.now(),
);
if (filteredValue.length) {
this.connectionsByIp.set(key, filteredValue);
} else {
this.connectionsByIp.delete(key);
}
});
this.bannedIps.forEach((value, key) => {
if (!this.isBanned(key)) {
this.bannedIps.delete(key);
}
});
}
isBanned(ip: string) {
const bannedAt = this.bannedIps.get(ip) || 0;
return Date.now() < bannedAt + this.configuration.banTime * 60 * 1000;
}
/**
* Throttle requests
* @private
*/
private throttle(ip: string): boolean {
if (!this.configuration.throttle) {
return false;
}
if (this.isBanned(ip)) return true;
this.bannedIps.delete(ip);
// add this connection try to the list of previous connections
const previousConnections = this.connectionsByIp.get(ip) || [];
previousConnections.push(Date.now());
// calculate the previous connections in the last considered time interval
const previousConnectionsInTheConsideredInterval =
previousConnections.filter(
(timestamp) =>
timestamp + this.configuration.consideredSeconds * 1000 > Date.now(),
);
this.connectionsByIp.set(ip, previousConnectionsInTheConsideredInterval);
if (
previousConnectionsInTheConsideredInterval.length >
this.configuration.throttle
) {
this.bannedIps.set(ip, Date.now());
return true;
}
return false;
}
/**
* onConnect hook
* @param data
*/
onConnect(data: onConnectPayload): Promise<any> {
const { request } = data;
// get the remote ip address from headers (web standard Request doesn't have socket)
const ip =
request.headers.get("x-real-ip") ||
request.headers.get("x-forwarded-for") ||
"";
// throttle the connection
return this.throttle(<string>ip) ? Promise.reject() : Promise.resolve();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# @hocuspocus/extension-webhook
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-webhook.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-webhook.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-webhook.svg)](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Send HTTP webhooks for [Hocuspocus](https://github.com/ueberdosis/hocuspocus) document lifecycle events — document changes, connects, disconnects, and creation. Lets you integrate the collaboration backend with your own services (search indexing, audit logs, downstream data pipelines) without writing a custom extension.
## Installation
```bash
npm install @hocuspocus/extension-webhook
```
## Usage
```js
import { Server } from "@hocuspocus/server"
import { Webhook, Events } from "@hocuspocus/extension-webhook"
const server = new Server({
extensions: [
new Webhook({
url: "https://example.com/hocuspocus-webhook",
secret: "your-signing-secret",
events: [Events.onChange, Events.onConnect, Events.onDisconnect, Events.onCreate],
}),
],
})
server.listen()
```
Requests are signed with HMAC-SHA256 using your `secret`, sent in the `X-Hocuspocus-Signature-256` header so the receiver can verify authenticity.
`onChange` events are debounced (default `2000ms`, max wait `10000ms`) to avoid a request per keystroke — tune with the `debounce` and `debounceMaxWait` options.
## Documentation
Payload shapes, signature verification, and debounce tuning: [tiptap.dev/docs/hocuspocus/server/extensions/webhook](https://tiptap.dev/docs/hocuspocus/server/extensions/webhook).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,47 @@
{
"name": "@hocuspocus/extension-webhook",
"version": "4.7.0",
"description": "hocuspocus webhook extension",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"webhook",
"api",
"rest",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-webhook.cjs",
"module": "dist/hocuspocus-webhook.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-webhook.esm.js",
"require": "./dist/hocuspocus-webhook.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/common": "workspace:^",
"@hocuspocus/server": "workspace:^",
"@hocuspocus/transformer": "workspace:^"
},
"peerDependencies": {
"yjs": "^13.6.8"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,227 @@
import { createHmac } from "node:crypto";
import { Forbidden } from "@hocuspocus/common";
import type {
Extension,
onChangePayload,
onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
} from "@hocuspocus/server";
import type { Transformer } from "@hocuspocus/transformer";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type { Doc } from "yjs";
export enum Events {
onChange = "change",
onConnect = "connect",
onCreate = "create",
onDisconnect = "disconnect",
}
export interface Configuration {
debounce: number | false | null;
debounceMaxWait: number;
secret: string;
transformer:
| Transformer
| {
toYdoc: (document: any) => Doc;
fromYdoc: (document: Doc) => any;
};
url: string;
events: Array<Events>;
}
export class Webhook implements Extension {
configuration: Configuration = {
debounce: 2000,
debounceMaxWait: 10000,
secret: "",
transformer: TiptapTransformer,
url: "",
events: [Events.onChange],
};
debounced: Map<string, { timeout: NodeJS.Timeout; start: number }> =
new Map();
/**
* Constructor
*/
constructor(configuration?: Partial<Configuration>) {
this.configuration = {
...this.configuration,
...configuration,
};
if (!this.configuration.url) {
throw new Error("url is required!");
}
}
/**
* Create a signature for the response body
*/
createSignature(body: string): string {
const hmac = createHmac("sha256", this.configuration.secret);
return `sha256=${hmac.update(body).digest("hex")}`;
}
/**
* debounce the given function, using the given identifier
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
debounce(id: string, func: Function) {
const old = this.debounced.get(id);
const start = old?.start || Date.now();
const run = () => {
this.debounced.delete(id);
func();
};
if (old?.timeout) clearTimeout(old.timeout);
if (Date.now() - start >= this.configuration.debounceMaxWait) return run();
this.debounced.set(id, {
start,
timeout: setTimeout(run, <number>this.configuration.debounce),
});
}
/**
* Send a request to the given url containing the given data
*/
async sendRequest(event: Events, payload: any) {
const json = JSON.stringify({ event, payload });
const response = await fetch(this.configuration.url, {
method: "POST",
body: json,
headers: {
"X-Hocuspocus-Signature-256": this.createSignature(json),
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
`Webhook request to ${this.configuration.url} failed with status ${response.status}`,
);
}
const text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
const data =
contentType.includes("application/json") && text
? JSON.parse(text)
: text;
return { status: response.status, data };
}
/**
* onChange hook
*/
async onChange(data: onChangePayload) {
if (!this.configuration.events.includes(Events.onChange)) {
return;
}
const save = async () => {
try {
await this.sendRequest(Events.onChange, {
document: this.configuration.transformer.fromYdoc(data.document),
documentName: data.documentName,
context: data.context,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(
data.requestParameters.entries(),
),
});
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
};
if (!this.configuration.debounce) {
return save();
}
this.debounce(data.documentName, save);
}
/**
* onLoadDocument hook
*/
async onLoadDocument(data: onLoadDocumentPayload) {
if (!this.configuration.events.includes(Events.onCreate)) {
return;
}
try {
const response = await this.sendRequest(Events.onCreate, {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
});
const document = response.data;
if (!document) return;
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const fieldName in document) {
if (data.document.isEmpty(fieldName)) {
data.document.merge(
this.configuration.transformer.toYdoc(
document[fieldName],
fieldName,
),
);
}
}
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
}
/**
* onConnect hook
*/
async onConnect(data: onConnectPayload) {
if (!this.configuration.events.includes(Events.onConnect)) {
return;
}
try {
const response = await this.sendRequest(Events.onConnect, {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
});
return response.data;
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
throw Forbidden;
}
}
async onDisconnect(data: onDisconnectPayload) {
if (!this.configuration.events.includes(Events.onDisconnect)) {
return;
}
try {
await this.sendRequest(Events.onDisconnect, {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
context: data.context,
});
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
}
}

View File

@@ -0,0 +1,138 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [4.7.0](https://github.com/ueberdosis/hocuspocus/compare/v4.6.0...v4.7.0) (2026-09-09)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.6.0](https://github.com/ueberdosis/hocuspocus/compare/v4.5.0...v4.6.0) (2026-08-10)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.5.0](https://github.com/ueberdosis/hocuspocus/compare/v4.4.0...v4.5.0) (2026-08-04)
### Bug Fixes
* update packages via audit --fix ([1dc9ca0](https://github.com/ueberdosis/hocuspocus/commit/1dc9ca0ff35f1033136473d134cee8cb6b336281))
# [4.4.0](https://github.com/ueberdosis/hocuspocus/compare/v4.3.0...v4.4.0) (2026-07-13)
### Features
* add `flushDelay` option for batching updates to reduce websocket traffic during heavy editing ([#1118](https://github.com/ueberdosis/hocuspocus/issues/1118)) ([75594c0](https://github.com/ueberdosis/hocuspocus/commit/75594c05d57d48f2f70d4c9440c28b8226bf95ac))
# [4.3.0](https://github.com/ueberdosis/hocuspocus/compare/v4.2.0...v4.3.0) (2026-06-18)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.2.0](https://github.com/ueberdosis/hocuspocus/compare/v4.1.2...v4.2.0) (2026-06-12)
**Note:** Version bump only for package @hocuspocus/provider-react
## [4.1.2](https://github.com/ueberdosis/hocuspocus/compare/v4.1.1...v4.1.2) (2026-06-11)
**Note:** Version bump only for package @hocuspocus/provider-react
## [4.1.1](https://github.com/ueberdosis/hocuspocus/compare/v4.1.0...v4.1.1) (2026-06-10)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.1.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0...v4.1.0) (2026-05-20)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.0.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.7...v4.0.0) (2026-04-23)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.0.0-rc.7](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.6...v4.0.0-rc.7) (2026-04-22)
**Note:** Version bump only for package @hocuspocus/provider-react
# [4.0.0-rc.6](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.5...v4.0.0-rc.6) (2026-04-20)
### Bug Fixes
* react provider in strict mode ([4f27b7f](https://github.com/ueberdosis/hocuspocus/commit/4f27b7f0e57423b08fabb59cfa6d0a2cc67bc996))
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-04-16)
### Features
* adds package readmes ([c7d63f7](https://github.com/ueberdosis/hocuspocus/commit/c7d63f79980de3c34e63b60073f22774bf27ac13))
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-04-16)
### Bug Fixes
* publishConfig for provider-react ([376765f](https://github.com/ueberdosis/hocuspocus/commit/376765f6623259c79940da67a1f970ac9a1e84b1))
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-04-16)
### Features
* first draft of @hocuspocus/provider-react package ([#1059](https://github.com/ueberdosis/hocuspocus/issues/1059)) ([cbc9977](https://github.com/ueberdosis/hocuspocus/commit/cbc99770096e8c674dae304c7e1a16e0fbec827b))

View File

@@ -0,0 +1,70 @@
# @hocuspocus/provider-react
[![Version](https://img.shields.io/npm/v/@hocuspocus/provider-react.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/provider-react)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/provider-react.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/provider-react.svg)](https://www.npmjs.com/package/@hocuspocus/provider-react)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
React bindings for the [Hocuspocus provider](../provider). Wraps the provider in components and hooks so React handles the lifecycle — including StrictMode double-mounts. Built on `useSyncExternalStore`.
## Installation
```bash
npm install @hocuspocus/provider @hocuspocus/provider-react yjs
```
Requires React 18 or 19.
## Usage
Wrap your collaborative subtree with `HocuspocusProviderWebsocketComponent` (shared WebSocket) and one or more `HocuspocusRoom`s (one per document). Inside the room, hooks give you the provider and its state:
```tsx
import {
HocuspocusProviderWebsocketComponent,
HocuspocusRoom,
useHocuspocusProvider,
useHocuspocusConnectionStatus,
} from "@hocuspocus/provider-react"
function Editor() {
const provider = useHocuspocusProvider()
const status = useHocuspocusConnectionStatus()
// wire provider.document / provider.awareness into your editor
return <div>{status}</div>
}
export function App() {
return (
<HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
<HocuspocusRoom name="example-document" token="super-secret-token">
<Editor />
</HocuspocusRoom>
</HocuspocusProviderWebsocketComponent>
)
}
```
To use with Tiptap, pass `provider.document` into the `Collaboration` extension and `provider` into `CollaborationCaret` — see the [collaborative editing guide](https://tiptap.dev/docs/hocuspocus/guides/collaborative-editing) for a full example.
## Exports
**Components**
- `HocuspocusProviderWebsocketComponent` — manages the shared WebSocket
- `HocuspocusRoom` — creates a per-document provider on the shared socket; StrictMode-safe
**Hooks** (must be used inside a `HocuspocusRoom`)
- `useHocuspocusProvider()` — the `HocuspocusProvider` instance
- `useHocuspocusConnectionStatus()` — `'connecting' | 'connected' | 'disconnected'`
- `useHocuspocusSyncStatus()` — `'synced' | 'syncing'`
- `useHocuspocusAwareness()` — array of connected users' awareness state
- `useHocuspocusEvent(name, handler)` — subscribe to any provider event
## Documentation
Full components, hooks, and patterns reference: [tiptap.dev/docs/hocuspocus/provider/react](https://tiptap.dev/docs/hocuspocus/provider/react).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,47 @@
{
"name": "@hocuspocus/provider-react",
"version": "4.7.0",
"description": "React bindings for Hocuspocus provider",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"websocket",
"provider",
"react",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-provider-react.cjs",
"module": "dist/hocuspocus-provider-react.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-provider-react.esm.js",
"require": "./dist/hocuspocus-provider-react.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"peerDependencies": {
"@hocuspocus/provider": "workspace:^",
"react": "^18.0.0 || ^19.0.0",
"yjs": "^13.6.8"
},
"devDependencies": {
"@types/react": "^19.2.14",
"react": "^19.2.4"
},
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,78 @@
"use client";
import { HocuspocusProviderWebsocket } from "@hocuspocus/provider";
import { useEffect, useMemo, useRef } from "react";
import { HocuspocusContext } from "./context.ts";
import type { HocuspocusProviderWebsocketComponentProps } from "./types.ts";
/**
* HocuspocusProviderWebsocketComponent manages the WebSocket connection that is shared across all rooms.
*
* This component creates a single WebSocket connection that can be used by multiple
* HocuspocusRoom components, preventing connection overhead when switching between documents.
*
* @example
* ```tsx
* <HocuspocusProviderWebsocketComponent url="ws://localhost:1234">
* <HocuspocusRoom name="document-1">
* <Editor />
* </HocuspocusRoom>
* </HocuspocusProviderWebsocketComponent>
* ```
*/
export function HocuspocusProviderWebsocketComponent({
children,
url,
websocketProvider: externalWebsocketProvider,
}: HocuspocusProviderWebsocketComponentProps) {
const websocketRef = useRef<HocuspocusProviderWebsocket | null>(null);
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Create WebSocket provider once on mount.
// Safe in StrictMode: useRef persists across double-renders, so the instance
// is created only once. The deferred destruction in the effect below ensures
// the second mount cancels the pending destroy before it fires.
if (!websocketRef.current && !externalWebsocketProvider) {
websocketRef.current = new HocuspocusProviderWebsocket({
url: url!,
});
}
const websocketProvider =
externalWebsocketProvider ??
(websocketRef.current as HocuspocusProviderWebsocket);
// Cleanup on unmount with deferred destruction to handle StrictMode double-mount
useEffect(() => {
if (destroyTimeoutRef.current) {
clearTimeout(destroyTimeoutRef.current);
destroyTimeoutRef.current = null;
}
return () => {
// Only destroy if we created the websocket (not externally provided)
if (!externalWebsocketProvider) {
destroyTimeoutRef.current = setTimeout(() => {
if (websocketRef.current) {
websocketRef.current.destroy();
websocketRef.current = null;
}
}, 0);
}
};
}, [externalWebsocketProvider]);
const contextValue = useMemo(
() => ({
websocketProvider,
}),
[websocketProvider],
);
return (
<HocuspocusContext.Provider value={contextValue}>
{children}
</HocuspocusContext.Provider>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
import type { HocuspocusProviderEvents, HocuspocusRoomProps } from "./types.ts";
/**
* Mapping from HocuspocusRoom `on*` prop names to provider event names.
*/
const EVENT_PROP_MAP: Record<string, keyof HocuspocusProviderEvents> = {
onOpen: "open",
onConnect: "connect",
onClose: "close",
onDisconnect: "disconnect",
onStatus: "status",
onSynced: "synced",
onUnsyncedChanges: "unsyncedChanges",
onMessage: "message",
onOutgoingMessage: "outgoingMessage",
onStateless: "stateless",
onAuthenticated: "authenticated",
onAuthenticationFailed: "authenticationFailed",
onAwarenessUpdate: "awarenessUpdate",
onAwarenessChange: "awarenessChange",
onDestroy: "destroy",
};
/**
* HocuspocusRoom manages the connection to a specific document.
*
* It uses the shared WebSocket from HocuspocusProviderWebsocketComponent and creates a document-specific
* provider that connects on mount and disconnects on unmount.
*
* This component handles React's StrictMode gracefully by using deferred destruction,
* preventing unnecessary reconnections during development double-mounts.
*
* @example
* ```tsx
* <HocuspocusProviderWebsocketComponent url="ws://localhost:1234">
* <HocuspocusRoom
* name="document-1"
* onAuthenticationFailed={(data) => console.error(data.reason)}
* >
* <Editor />
* </HocuspocusRoom>
* </HocuspocusProviderWebsocketComponent>
* ```
*/
export function HocuspocusRoom({
children,
name,
document,
token,
sessionAwareness,
flushDelay,
...eventHandlers
}: HocuspocusRoomProps) {
const hocuspocusContext = useContext(HocuspocusContext);
if (!hocuspocusContext) {
throw new Error(
"HocuspocusRoom must be used within a HocuspocusProviderWebsocketComponent",
);
}
const { websocketProvider } = hocuspocusContext;
const [provider, setProvider] = useState(
() =>
new HocuspocusProvider({
name,
websocketProvider,
document,
token,
sessionAwareness,
flushDelay,
}),
);
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Recreate provider when name, document, or token changes.
// Only compare `document`/`token` when the caller actually provided them —
// otherwise the provider's constructor-initialized defaults (auto-created
// Y.Doc, null token) will never match the undefined prop, causing a destroy
// on every render (and in StrictMode, a stray CloseMessage for the current
// doc once the provider is already attached).
// biome-ignore lint/correctness/useExhaustiveDependencies: provider.configuration holds the previous values we compare against — not a reactive dependency
useEffect(() => {
const shouldRecreate =
provider.configuration.name !== name ||
(document !== undefined &&
provider.configuration.document !== document) ||
(token !== undefined && provider.configuration.token !== token) ||
provider.configuration.websocketProvider !== websocketProvider;
if (shouldRecreate) {
provider.destroy();
setProvider(
new HocuspocusProvider({
name,
websocketProvider,
document,
token,
sessionAwareness,
flushDelay,
}),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name, document, token, websocketProvider]);
// Attach/detach lifecycle with deferred destruction for StrictMode
useEffect(() => {
if (destroyTimeoutRef.current) {
clearTimeout(destroyTimeoutRef.current);
destroyTimeoutRef.current = null;
}
provider.attach();
return () => {
destroyTimeoutRef.current = setTimeout(() => {
provider.destroy();
}, 0);
};
}, [provider]);
// Wire up on* event handler props with stable refs
const handlersRef = useRef(eventHandlers);
handlersRef.current = eventHandlers;
useEffect(() => {
const cleanups: (() => void)[] = [];
for (const [propName, eventName] of Object.entries(EVENT_PROP_MAP)) {
const listener = (...args: unknown[]) => {
const handler = handlersRef.current[
propName as keyof typeof handlersRef.current
] as ((...a: unknown[]) => void) | undefined;
handler?.(...args);
};
provider.on(eventName, listener);
cleanups.push(() => provider.off(eventName, listener));
}
return () => {
for (const cleanup of cleanups) {
cleanup();
}
};
}, [provider]);
const contextValue = useMemo(
() => ({
provider,
}),
[provider],
);
return (
<HocuspocusRoomContext.Provider value={contextValue}>
{children}
</HocuspocusRoomContext.Provider>
);
}

View File

@@ -0,0 +1,18 @@
import { createContext } from "react";
import type {
HocuspocusContextValue,
HocuspocusRoomContextValue,
} from "./types.ts";
/**
* Context for the WebSocket connection shared across rooms
*/
export const HocuspocusContext =
createContext<HocuspocusContextValue | null>(null);
/**
* Context for the room/document provider
*/
export const HocuspocusRoomContext =
createContext<HocuspocusRoomContextValue | null>(null);

View File

@@ -0,0 +1,5 @@
export { useHocuspocusAwareness } from "./useHocuspocusAwareness.ts";
export { useHocuspocusConnectionStatus } from "./useHocuspocusConnectionStatus.ts";
export { useHocuspocusEvent } from "./useHocuspocusEvent.ts";
export { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
export { useHocuspocusSyncStatus } from "./useHocuspocusSyncStatus.ts";

View File

@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import type { CollabUser } from "../types.ts";
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
/**
* Subscribe to the list of connected users in the document.
*
* This hook uses the Yjs awareness protocol to track users currently
* connected to the document.
*
* @returns Array of user objects with their awareness state
*
* @example
* ```tsx
* function UserList() {
* const users = useHocuspocusAwareness()
*
* return (
* <div className="avatars">
* {users.map(user => (
* <div
* key={user.clientId}
* style={{ backgroundColor: user.color }}
* title={user.name}
* >
* {user.name?.[0]}
* </div>
* ))}
* </div>
* )
* }
* ```
*/
export function useHocuspocusAwareness(): CollabUser[] {
const provider = useHocuspocusProvider();
const versionRef = useRef(0);
const cacheRef = useRef<{ users: CollabUser[]; version: number }>({
users: [],
version: -1,
});
// Reset cache when provider changes so we don't serve stale users
// from a previous provider instance.
useEffect(() => {
versionRef.current++;
cacheRef.current = { users: [], version: -1 };
}, [provider]);
const subscribe = useCallback(
(onStoreChange: () => void) => {
const onChange = () => {
versionRef.current++;
onStoreChange();
};
provider.awareness?.on("change", onChange);
return () => {
provider.awareness?.off("change", onChange);
};
},
[provider],
);
const getSnapshot = useCallback(() => {
const awareness = provider.awareness;
if (!awareness) {
return [];
}
// Return cached value if awareness hasn't changed since last snapshot
if (cacheRef.current.version === versionRef.current) {
return cacheRef.current.users;
}
const users: CollabUser[] = [];
awareness.getStates().forEach((state, clientId) => {
users.push({
clientId,
...state,
});
});
cacheRef.current = { users, version: versionRef.current };
return users;
}, [provider]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View File

@@ -0,0 +1,47 @@
import { useCallback, useSyncExternalStore } from "react";
import type { ConnectionStatus } from "../types.ts";
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
/**
* Subscribe to the connection status of the collaboration provider.
*
* This hook uses React's useSyncExternalStore for optimal integration with
* concurrent rendering features.
*
* @returns The current connection status: 'connecting', 'connected', or 'disconnected'
*
* @example
* ```tsx
* function ConnectionIndicator() {
* const status = useHocuspocusConnectionStatus()
*
* return (
* <div className={`status-${status}`}>
* {status === 'connected' ? 'Online' : status === 'connecting' ? 'Connecting...' : 'Offline'}
* </div>
* )
* }
* ```
*/
export function useHocuspocusConnectionStatus(): ConnectionStatus {
const provider = useHocuspocusProvider();
const subscribe = useCallback(
(onStoreChange: () => void) => {
provider.on("status", onStoreChange);
return () => {
provider.off("status", onStoreChange);
};
},
[provider],
);
const getSnapshot = useCallback(
(): ConnectionStatus =>
provider.configuration.websocketProvider.status as ConnectionStatus,
[provider],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
import type { HocuspocusProviderEvents } from "../types.ts";
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
/**
* Subscribe to events from the HocuspocusProvider for the current room.
*
* The handler is stored in a ref so the subscription stays stable even if
* the handler identity changes between renders.
*
* Must be used within a HocuspocusRoom component.
*
* @param event - The event name to subscribe to
* @param handler - Callback invoked when the event fires
*
* @example
* ```tsx
* function AuthGuard() {
* useHocuspocusEvent('authenticationFailed', (data) => {
* console.error('Auth failed:', data.reason)
* redirectToLogin()
* })
*
* useHocuspocusEvent('close', (data) => {
* console.log('Connection closed', data.event)
* })
*
* return null
* }
* ```
*/
export function useHocuspocusEvent<E extends keyof HocuspocusProviderEvents>(
event: E,
handler: HocuspocusProviderEvents[E] extends undefined
? () => void
: (data: HocuspocusProviderEvents[E]) => void,
): void {
const provider = useHocuspocusProvider();
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const listener = (...args: unknown[]) => {
(handlerRef.current as (...a: unknown[]) => void)(...args);
};
provider.on(event, listener);
return () => {
provider.off(event, listener);
};
}, [provider, event]);
}

View File

@@ -0,0 +1,37 @@
import { useContext } from "react";
import { HocuspocusRoomContext } from "../context.ts";
/**
* Access the HocuspocusProvider instance for the current room.
*
* Must be used within a HocuspocusRoom component.
*
* @returns The HocuspocusProvider instance
* @throws Error if used outside of HocuspocusRoom
*
* @example
* ```tsx
* function Editor() {
* const provider = useHocuspocusProvider()
*
* const editor = useEditor({
* extensions: [
* Collaboration.configure({ document: provider.document }),
* CollaborationCursor.configure({ provider }),
* ],
* })
*
* return <EditorContent editor={editor} />
* }
* ```
*/
export function useHocuspocusProvider() {
const context = useContext(HocuspocusRoomContext);
if (!context) {
throw new Error("useHocuspocusProvider must be used within a HocuspocusRoom");
}
return context.provider;
}

View File

@@ -0,0 +1,51 @@
import { useCallback, useSyncExternalStore } from "react";
import type { SyncStatus } from "../types.ts";
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
/**
* Subscribe to the sync status of local changes.
*
* This hook indicates whether local changes have been synced with the server.
*
* @returns The current sync status: 'synced' or 'syncing'
*
* @example
* ```tsx
* function SaveIndicator() {
* const syncStatus = useHocuspocusSyncStatus()
*
* return (
* <div>
* {syncStatus === 'syncing' ? 'Saving...' : 'All changes saved'}
* </div>
* )
* }
* ```
*/
export function useHocuspocusSyncStatus(): SyncStatus {
const provider = useHocuspocusProvider();
const subscribe = useCallback(
(onStoreChange: () => void) => {
provider.on("synced", onStoreChange);
provider.on("unsyncedChanges", onStoreChange);
return () => {
provider.off("synced", onStoreChange);
provider.off("unsyncedChanges", onStoreChange);
};
},
[provider],
);
const getSnapshot = useCallback((): SyncStatus => {
// Check if there are unsynced changes
if (provider.unsyncedChanges > 0) {
return "syncing";
}
return "synced";
}, [provider]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View File

@@ -0,0 +1,29 @@
"use client";
// Contexts
export { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
// Components
export { HocuspocusProviderWebsocketComponent } from "./HocuspocusProviderWebsocketComponent.tsx";
export { HocuspocusRoom } from "./HocuspocusRoom.tsx";
// Hooks
export {
useHocuspocusAwareness,
useHocuspocusConnectionStatus,
useHocuspocusEvent,
useHocuspocusProvider,
useHocuspocusSyncStatus,
} from "./hooks/index.ts";
// Types
export type {
CollabUser,
ConnectionStatus,
HocuspocusContextValue,
HocuspocusProviderEvents,
HocuspocusProviderWebsocketComponentProps,
HocuspocusRoomContextValue,
HocuspocusRoomProps,
SyncStatus,
} from "./types.ts";

View File

@@ -0,0 +1,155 @@
import type {
HocuspocusProvider,
HocuspocusProviderWebsocket,
onAuthenticatedParameters,
onAuthenticationFailedParameters,
onAwarenessChangeParameters,
onAwarenessUpdateParameters,
onCloseParameters,
onDisconnectParameters,
onMessageParameters,
onOpenParameters,
onOutgoingMessageParameters,
onStatelessParameters,
onStatusParameters,
onSyncedParameters,
onUnsyncedChangesParameters,
} from "@hocuspocus/provider";
import type { ReactNode } from "react";
import type * as Y from "yjs";
/**
* Configuration for HocuspocusProvider component.
*
* Provide either a `url` to create a managed WebSocket connection,
* or a `websocketProvider` instance for full control — but not both.
*/
export type HocuspocusProviderWebsocketComponentProps = {
children: ReactNode;
} & (
| {
/**
* URL of your @hocuspocus/server instance
*/
url: string;
websocketProvider?: never;
}
| {
url?: never;
/**
* Provide your own websocket instance for full control
*/
websocketProvider: HocuspocusProviderWebsocket;
}
);
/**
* Map of HocuspocusProvider event names to their payload types.
* Used by `useHocuspocusEvent` and the `HocuspocusRoom` event handler props.
*/
export interface HocuspocusProviderEvents {
open: onOpenParameters;
connect: undefined;
close: onCloseParameters;
disconnect: onDisconnectParameters;
status: onStatusParameters;
synced: onSyncedParameters;
unsyncedChanges: onUnsyncedChangesParameters;
message: onMessageParameters;
outgoingMessage: onOutgoingMessageParameters;
stateless: onStatelessParameters;
authenticated: onAuthenticatedParameters;
authenticationFailed: onAuthenticationFailedParameters;
awarenessUpdate: onAwarenessUpdateParameters;
awarenessChange: onAwarenessChangeParameters;
destroy: undefined;
}
/**
* Configuration for HocuspocusRoom component
*/
export interface HocuspocusRoomProps {
children: ReactNode;
/**
* The document name (required)
*/
name: string;
/**
* Optional: bring your own Y.Doc
*/
document?: Y.Doc;
/**
* JWT token or function that returns a promise resolving to a token
*/
token?: string | (() => Promise<string>) | (() => string);
/**
* Enable session-aware multiplexing. When true, the provider embeds a unique
* sessionId in the documentName field of every message, allowing multiple
* providers with the same document name on a single WebSocket connection.
*
* Only set this to `true` when connecting to a v4 server that does
* support session awareness.
*
* Default: false
*/
sessionAwareness?: boolean;
/**
* Batch outgoing document and awareness updates over a short window (in
* milliseconds) instead of sending one message per change, to reduce the
* number of websocket messages during heavy editing. The added latency is
* capped at `flushDelay`, so keep it small (e.g. 500).
*
* Default: false (send every change immediately)
*/
flushDelay?: false | number;
// Event handlers — all optional, called when the provider emits the corresponding event.
onOpen?: (data: onOpenParameters) => void;
onConnect?: () => void;
onClose?: (data: onCloseParameters) => void;
onDisconnect?: (data: onDisconnectParameters) => void;
onStatus?: (data: onStatusParameters) => void;
onSynced?: (data: onSyncedParameters) => void;
onUnsyncedChanges?: (data: onUnsyncedChangesParameters) => void;
onMessage?: (data: onMessageParameters) => void;
onOutgoingMessage?: (data: onOutgoingMessageParameters) => void;
onStateless?: (data: onStatelessParameters) => void;
onAuthenticated?: (data: onAuthenticatedParameters) => void;
onAuthenticationFailed?: (data: onAuthenticationFailedParameters) => void;
onAwarenessUpdate?: (data: onAwarenessUpdateParameters) => void;
onAwarenessChange?: (data: onAwarenessChangeParameters) => void;
onDestroy?: () => void;
}
/**
* Context value for the WebSocket provider
*/
export interface HocuspocusContextValue {
websocketProvider: HocuspocusProviderWebsocket;
}
/**
* Context value for the room/document provider
*/
export interface HocuspocusRoomContextValue {
provider: HocuspocusProvider;
}
/**
* Connection status for the collaboration provider
*/
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
/**
* Sync status indicating whether local changes are synced with server
*/
export type SyncStatus = "synced" | "syncing";
/**
* User information from awareness
*/
export interface CollabUser {
clientId: number;
[key: string]: unknown;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
# @hocuspocus/provider
[![Version](https://img.shields.io/npm/v/@hocuspocus/provider.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/provider)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/provider.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/provider.svg)](https://www.npmjs.com/package/@hocuspocus/provider)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
The client-side provider for [Hocuspocus](https://github.com/ueberdosis/hocuspocus). Connects to a Hocuspocus server over WebSockets and syncs one or more [Y.js](https://github.com/yjs/yjs) documents — including awareness (presence), authentication, and stateless messaging.
> **Building a React app?** Use [`@hocuspocus/provider-react`](../provider-react) instead — it wraps the provider in components and hooks so React handles the lifecycle for you (including StrictMode double-mounts).
## Installation
```bash
npm install @hocuspocus/provider yjs
```
## Usage
```js
import * as Y from "yjs"
import { HocuspocusProvider } from "@hocuspocus/provider"
const ydoc = new Y.Doc()
const provider = new HocuspocusProvider({
url: "ws://127.0.0.1:1234",
name: "example-document",
document: ydoc,
})
```
Changes to `ydoc` are now synced to every other client connected to the same `name`.
### Authenticating
Pass a `token` — it's forwarded to the server's `onAuthenticate` hook:
```js
new HocuspocusProvider({
url: "wss://collab.example.com",
name: "example-document",
document: ydoc,
token: "super-secret-token",
})
```
### Sharing a socket across documents
Create a `HocuspocusProviderWebsocket` once, then reuse it for multiple documents:
```js
import {
HocuspocusProvider,
HocuspocusProviderWebsocket,
} from "@hocuspocus/provider"
const socket = new HocuspocusProviderWebsocket({ url: "ws://127.0.0.1:1234" })
const doc1 = new HocuspocusProvider({ websocketProvider: socket, name: "doc-1" })
const doc2 = new HocuspocusProvider({ websocketProvider: socket, name: "doc-2" })
```
Call `provider.destroy()` to disconnect a single document. Call `socket.destroy()` to tear down the shared connection.
## Documentation
Full configuration, events, and awareness reference: [tiptap.dev/docs/hocuspocus/provider](https://tiptap.dev/docs/hocuspocus/provider/overview).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,47 @@
{
"name": "@hocuspocus/provider",
"version": "4.7.0",
"description": "hocuspocus provider",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"websocket",
"provider",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-provider.cjs",
"module": "dist/hocuspocus-provider.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-provider.esm.js",
"require": "./dist/hocuspocus-provider.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/common": "workspace:^",
"@lifeomic/attempt": "^3.1.0",
"lib0": "^0.2.117"
},
"peerDependencies": {
"y-protocols": "^1.0.6",
"yjs": "^13.6.8"
},
"gitHead": "cd788b6a315f608ef531524409abdce1e6790726",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,44 @@
export default class EventEmitter {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
public callbacks: { [key: string]: Function[] } = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
public on(event: string, fn: Function): this {
if (!this.callbacks[event]) {
this.callbacks[event] = [];
}
this.callbacks[event].push(fn);
return this;
}
protected emit(event: string, ...args: any): this {
const callbacks = this.callbacks[event];
if (callbacks) {
callbacks.forEach((callback) => callback.apply(this, args));
}
return this;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
public off(event: string, fn?: Function): this {
const callbacks = this.callbacks[event];
if (callbacks) {
if (fn) {
this.callbacks[event] = callbacks.filter((callback) => callback !== fn);
} else {
delete this.callbacks[event];
}
}
return this;
}
removeAllListeners(): void {
this.callbacks = {};
}
}

View File

@@ -0,0 +1,760 @@
import {
awarenessStatesToArray,
makeRoutingKey,
parseRoutingKey,
} from "@hocuspocus/common";
import { Awareness, removeAwarenessStates } from "y-protocols/awareness";
import * as Y from "yjs";
import EventEmitter from "./EventEmitter.ts";
import type { CompleteHocuspocusProviderWebsocketConfiguration } from "./HocuspocusProviderWebsocket.ts";
import { HocuspocusProviderWebsocket } from "./HocuspocusProviderWebsocket.ts";
import { IncomingMessage } from "./IncomingMessage.ts";
import { MessageReceiver } from "./MessageReceiver.ts";
import { MessageSender } from "./MessageSender.ts";
import { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.ts";
import { AwarenessMessage } from "./OutgoingMessages/AwarenessMessage.ts";
import { StatelessMessage } from "./OutgoingMessages/StatelessMessage.ts";
import { SyncStepOneMessage } from "./OutgoingMessages/SyncStepOneMessage.ts";
import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
import type {
AuthorizedScope,
ConstructableOutgoingMessage,
onAuthenticatedParameters,
onAuthenticationFailedParameters,
onAwarenessChangeParameters,
onAwarenessUpdateParameters,
onCloseParameters,
onDisconnectParameters,
onMessageParameters,
onOpenParameters,
onOutgoingMessageParameters,
onStatelessParameters,
onStatusParameters,
onSyncedParameters,
onUnsyncedChangesParameters,
} from "./types.ts";
export type HocuspocusProviderConfiguration = Required<
Pick<CompleteHocuspocusProviderConfiguration, "name">
> &
Partial<CompleteHocuspocusProviderConfiguration> &
(
| (Required<Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">> &
Partial<
Pick<
CompleteHocuspocusProviderWebsocketConfiguration,
"preserveTrailingSlash"
>
>)
| Required<
Pick<CompleteHocuspocusProviderConfiguration, "websocketProvider">
>
);
export interface CompleteHocuspocusProviderConfiguration {
/**
* The identifier/name of your document
*/
name: string;
/**
* The actual Y.js document
*/
document: Y.Doc;
/**
* An Awareness instance to keep the presence state of all clients.
*
* You can disable sharing awareness information by passing `null`.
* Note that having no awareness information shared across all connections will break our ping checks
* and thus trigger reconnects. You should always have at least one Provider with enabled awareness per
* socket connection, or ensure that the Provider receives messages before running into `HocuspocusProviderWebsocket.messageReconnectTimeout`.
*/
awareness: Awareness | null;
/**
* A token that’s sent to the backend for authentication purposes.
*/
token: string | (() => string) | (() => Promise<string>) | null;
/**
* Hocuspocus websocket provider
*/
websocketProvider: HocuspocusProviderWebsocket;
/**
* Enable session-aware multiplexing. When true, the provider embeds a unique
* sessionId in the documentName field of every message, allowing multiple
* providers with the same document name on a single WebSocket connection.
*
* Only set this to `true` when connecting to a v4 server that does
* support session awareness.
*
* Default: false
*/
sessionAwareness: boolean;
/**
* Force syncing the document in the defined interval.
*/
forceSyncInterval: false | number;
/**
* Batch outgoing document and awareness updates over a short window (in
* milliseconds) instead of sending one message per change. During heavy
* editing this drastically reduces the number of websocket messages: Yjs
* updates collected in the window are merged with `Y.mergeUpdates` into a
* single message, and awareness collapses to the latest state of each
* changed client.
*
* The window is a fixed batch, not a resetting debounce, so the added
* latency is capped at `flushDelay` even while the user keeps typing. Keep
* it small (e.g. 500) to avoid delaying what other clients see.
*
* Set to `false` (the default) to send every change immediately.
*/
flushDelay: false | number;
onAuthenticated: (data: onAuthenticatedParameters) => void;
onAuthenticationFailed: (data: onAuthenticationFailedParameters) => void;
onOpen: (data: onOpenParameters) => void;
onConnect: () => void;
onStatus: (data: onStatusParameters) => void;
onMessage: (data: onMessageParameters) => void;
onOutgoingMessage: (data: onOutgoingMessageParameters) => void;
onSynced: (data: onSyncedParameters) => void;
onDisconnect: (data: onDisconnectParameters) => void;
onClose: (data: onCloseParameters) => void;
onDestroy: () => void;
onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
onAwarenessChange: (data: onAwarenessChangeParameters) => void;
onStateless: (data: onStatelessParameters) => void;
onUnsyncedChanges: (data: onUnsyncedChangesParameters) => void;
}
export class AwarenessError extends Error {
code = 1001;
}
export class HocuspocusProvider extends EventEmitter {
public configuration: CompleteHocuspocusProviderConfiguration = {
name: "",
// @ts-expect-error
document: undefined,
// @ts-expect-error
awareness: undefined,
token: null,
sessionAwareness: false,
forceSyncInterval: false,
flushDelay: false,
onAuthenticated: () => null,
onAuthenticationFailed: () => null,
onOpen: () => null,
onConnect: () => null,
onMessage: () => null,
onOutgoingMessage: () => null,
onSynced: () => null,
onStatus: () => null,
onDisconnect: () => null,
onClose: () => null,
onDestroy: () => null,
onAwarenessUpdate: () => null,
onAwarenessChange: () => null,
onStateless: () => null,
onUnsyncedChanges: () => null,
};
isSynced = false;
unsyncedChanges = 0;
isAuthenticated = false;
authorizedScope: AuthorizedScope | undefined = undefined;
// @internal
manageSocket = false;
private _isAttached = false;
/**
* Outgoing document updates buffered for the current `flushDelay` window.
*/
private pendingUpdates: Uint8Array[] = [];
/**
* Awareness client ids whose latest state should be sent on the next flush.
*/
private pendingAwarenessClients = new Set<number>();
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
/**
* Unique session identifier for this provider instance.
* Used for multiplexing multiple providers with the same document name on a single WebSocket.
*/
sessionId: string = Math.random().toString(36).slice(2);
/**
* The effective name used as the first VarString in messages.
* When `sessionAwareness` is enabled, returns a composite key (documentName\0sessionId).
* Otherwise, returns the plain document name.
*/
get effectiveName(): string {
return this.configuration.sessionAwareness
? makeRoutingKey(this.configuration.name, this.sessionId)
: this.configuration.name;
}
intervals: any = {
forceSync: null,
};
constructor(configuration: HocuspocusProviderConfiguration) {
super();
this.setConfiguration(configuration);
this.configuration.document = configuration.document
? configuration.document
: new Y.Doc();
this.configuration.awareness =
configuration.awareness !== undefined
? configuration.awareness
: new Awareness(this.document);
this.on("open", this.configuration.onOpen);
this.on("message", this.configuration.onMessage);
this.on("outgoingMessage", this.configuration.onOutgoingMessage);
this.on("synced", this.configuration.onSynced);
this.on("destroy", this.configuration.onDestroy);
this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
this.on("awarenessChange", this.configuration.onAwarenessChange);
this.on("stateless", this.configuration.onStateless);
this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
this.on("authenticated", this.configuration.onAuthenticated);
this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
this.awareness?.on("update", () => {
this.emit("awarenessUpdate", {
states: awarenessStatesToArray(this.awareness!.getStates()),
});
});
this.awareness?.on("change", () => {
this.emit("awarenessChange", {
states: awarenessStatesToArray(this.awareness!.getStates()),
});
});
this.document.on("update", this.boundDocumentUpdateHandler);
this.awareness?.on("update", this.boundAwarenessUpdateHandler);
this.registerEventListeners();
if (
this.configuration.forceSyncInterval &&
typeof this.configuration.forceSyncInterval === "number"
) {
this.intervals.forceSync = setInterval(
this.forceSync.bind(this),
this.configuration.forceSyncInterval,
);
}
if (this.manageSocket) {
this.attach();
}
}
boundDocumentUpdateHandler = this.documentUpdateHandler.bind(this);
boundAwarenessUpdateHandler = this.awarenessUpdateHandler.bind(this);
boundPageHide = this.pageHide.bind(this);
boundOnOpen = this.onOpen.bind(this);
boundOnClose = this.onClose.bind(this);
forwardConnect = () => this.emit("connect");
forwardStatus = (e: onStatusParameters) => this.emit("status", e);
forwardClose = (e: onCloseParameters) => this.emit("close", e);
forwardDisconnect = (e: onDisconnectParameters) => this.emit("disconnect", e);
forwardDestroy = () => this.emit("destroy");
public setConfiguration(
configuration: Partial<HocuspocusProviderConfiguration> = {},
): void {
if (!configuration.websocketProvider) {
this.manageSocket = true;
this.configuration.websocketProvider = new HocuspocusProviderWebsocket(
configuration as CompleteHocuspocusProviderWebsocketConfiguration,
);
}
this.configuration = { ...this.configuration, ...configuration };
}
get document() {
return this.configuration.document;
}
public get isAttached() {
return this._isAttached;
}
get awareness() {
return this.configuration.awareness;
}
get hasUnsyncedChanges(): boolean {
return this.unsyncedChanges > 0;
}
private resetUnsyncedChanges() {
this.unsyncedChanges = 1;
this.emit("unsyncedChanges", { number: this.unsyncedChanges });
}
incrementUnsyncedChanges() {
this.unsyncedChanges += 1;
this.emit("unsyncedChanges", { number: this.unsyncedChanges });
}
decrementUnsyncedChanges() {
if (this.unsyncedChanges > 0) {
this.unsyncedChanges -= 1;
}
if (this.unsyncedChanges === 0) {
this.synced = true;
}
this.emit("unsyncedChanges", { number: this.unsyncedChanges });
}
forceSync() {
this.resetUnsyncedChanges();
this.send(SyncStepOneMessage, {
document: this.document,
documentName: this.effectiveName,
});
}
pageHide() {
if (this.awareness) {
removeAwarenessStates(
this.awareness,
[this.document.clientID],
"page hide",
);
}
}
registerEventListeners() {
if (typeof window === "undefined" || !("addEventListener" in window)) {
return;
}
window.addEventListener("pagehide", this.boundPageHide);
}
sendStateless(payload: string) {
this.send(StatelessMessage, {
documentName: this.effectiveName,
payload,
});
}
async sendToken() {
let token: string | null;
try {
token = await this.getToken();
} catch (error) {
this.permissionDeniedHandler(
`Failed to get token during sendToken(): ${error}`,
);
return;
}
this.send(AuthenticationMessage, {
token: token ?? "",
documentName: this.effectiveName,
});
}
private get batchingEnabled(): boolean {
return (
!!this.configuration.flushDelay &&
typeof this.configuration.flushDelay === "number"
);
}
documentUpdateHandler(update: Uint8Array, origin: any) {
if (origin === this) {
return;
}
if (!this.batchingEnabled) {
this.incrementUnsyncedChanges();
this.send(UpdateMessage, { update, documentName: this.effectiveName });
return;
}
// Count one outstanding change per batch: the server acks once per
// merged message, so incrementing per buffered update would never balance.
if (this.pendingUpdates.length === 0) {
this.incrementUnsyncedChanges();
}
this.pendingUpdates.push(update);
this.scheduleFlush();
}
awarenessUpdateHandler({ added, updated, removed }: any, origin: any) {
if (origin === this) {
return;
}
const changedClients = added.concat(updated).concat(removed);
if (!this.batchingEnabled) {
this.send(AwarenessMessage, {
awareness: this.awareness,
clients: changedClients,
documentName: this.effectiveName,
});
return;
}
for (const client of changedClients) {
this.pendingAwarenessClients.add(client);
}
this.scheduleFlush();
}
private scheduleFlush() {
// Fixed-window batching: the first pending change starts the timer and
// everything until it fires is flushed together, so latency stays capped
// at `flushDelay` instead of growing while the user keeps typing.
if (this.flushTimeout !== null) {
return;
}
this.flushTimeout = setTimeout(() => {
this.flushTimeout = null;
this.flushPendingUpdates();
}, this.configuration.flushDelay as number);
}
/**
* Send everything buffered for the current `flushDelay` window right away.
* Buffered document updates are merged into a single message and awareness
* collapses to the latest state of each changed client. Safe to call when
* nothing is pending.
*/
flushPendingUpdates() {
if (this.flushTimeout !== null) {
clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
if (this.pendingUpdates.length > 0) {
const update =
this.pendingUpdates.length === 1
? this.pendingUpdates[0]
: Y.mergeUpdates(this.pendingUpdates);
this.pendingUpdates = [];
this.send(UpdateMessage, { update, documentName: this.effectiveName });
}
if (this.pendingAwarenessClients.size > 0) {
const clients = Array.from(this.pendingAwarenessClients);
this.pendingAwarenessClients.clear();
this.send(AwarenessMessage, {
awareness: this.awareness,
clients,
documentName: this.effectiveName,
});
}
}
/**
* Indicates whether a first handshake with the server has been established
*
* Note: this does not mean all updates from the client have been persisted to the backend. For this,
* use `hasUnsyncedChanges`.
*/
get synced(): boolean {
return this.isSynced;
}
set synced(state) {
if (this.isSynced === state) {
return;
}
this.isSynced = state;
if (state) {
this.emit("synced", { state });
}
}
receiveStateless(payload: string) {
this.emit("stateless", { payload });
}
// not needed, but provides backward compatibility with e.g. lexical/yjs
async connect() {
if (this.manageSocket) {
return this.configuration.websocketProvider.connect();
}
console.warn(
"HocuspocusProvider::connect() is deprecated and does not do anything. Please connect/disconnect on the websocketProvider, or attach/deattach providers.",
);
}
disconnect() {
if (this.manageSocket) {
return this.configuration.websocketProvider.disconnect();
}
console.warn(
"HocuspocusProvider::disconnect() is deprecated and does not do anything. Please connect/disconnect on the websocketProvider, or attach/deattach providers.",
);
}
async onOpen(event: Event) {
this.isAuthenticated = false;
this.emit("open", { event });
await this.sendToken();
this.startSync();
}
async getToken() {
if (typeof this.configuration.token === "function") {
const token = await this.configuration.token();
return token;
}
return this.configuration.token;
}
startSync() {
this.resetUnsyncedChanges();
this.send(SyncStepOneMessage, {
document: this.document,
documentName: this.effectiveName,
});
if (this.awareness && this.awareness.getLocalState() !== null) {
this.send(AwarenessMessage, {
awareness: this.awareness,
clients: [this.document.clientID],
documentName: this.effectiveName,
});
}
}
send(message: ConstructableOutgoingMessage, args: any) {
if (!this._isAttached) return;
const messageSender = new MessageSender(message, args);
this.emit("outgoingMessage", { message: messageSender.message });
messageSender.send(this.configuration.websocketProvider);
}
onMessage(event: MessageEvent) {
const message = new IncomingMessage(event.data);
const rawKey = message.readVarString();
// Extract actual documentName from potentially composite routing key
const { documentName } = parseRoutingKey(rawKey);
message.writeVarString(this.effectiveName);
this.emit("message", { event, message: new IncomingMessage(event.data) });
new MessageReceiver(message).apply(this, true);
}
onClose() {
this.isAuthenticated = false;
this.synced = false;
// Drop anything buffered for batching; the reconnect sync handshake will
// reconcile these changes from the document via state vectors.
if (this.flushTimeout !== null) {
clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
this.pendingUpdates = [];
this.pendingAwarenessClients.clear();
// update awareness (all users except local left)
if (this.awareness) {
removeAwarenessStates(
this.awareness,
Array.from(this.awareness.getStates().keys()).filter(
(client) => client !== this.document.clientID,
),
this,
);
}
}
destroy() {
this.emit("destroy");
if (this.intervals.forceSync) {
clearInterval(this.intervals.forceSync);
}
if (this.awareness) {
removeAwarenessStates(
this.awareness,
[this.document.clientID],
"provider destroy",
);
this.awareness.off("update", this.boundAwarenessUpdateHandler);
this.awareness.destroy();
}
// Send any buffered updates before the socket is torn down. The websocket
// safely queues the message if it is no longer open.
this.flushPendingUpdates();
this.document.off("update", this.boundDocumentUpdateHandler);
this.removeAllListeners();
this.detach();
if (this.manageSocket) {
this.configuration.websocketProvider.destroy();
}
if (typeof window === "undefined" || !("removeEventListener" in window)) {
return;
}
window.removeEventListener("pagehide", this.boundPageHide);
}
detach() {
this.configuration.websocketProvider.off(
"connect",
this.configuration.onConnect,
);
this.configuration.websocketProvider.off("connect", this.forwardConnect);
this.configuration.websocketProvider.off("status", this.forwardStatus);
this.configuration.websocketProvider.off(
"status",
this.configuration.onStatus,
);
this.configuration.websocketProvider.off("open", this.boundOnOpen);
this.configuration.websocketProvider.off("close", this.boundOnClose);
this.configuration.websocketProvider.off(
"close",
this.configuration.onClose,
);
this.configuration.websocketProvider.off("close", this.forwardClose);
this.configuration.websocketProvider.off(
"disconnect",
this.configuration.onDisconnect,
);
this.configuration.websocketProvider.off(
"disconnect",
this.forwardDisconnect,
);
this.configuration.websocketProvider.off(
"destroy",
this.configuration.onDestroy,
);
this.configuration.websocketProvider.off("destroy", this.forwardDestroy);
this.configuration.websocketProvider.detach(this);
this._isAttached = false;
}
attach() {
if (this._isAttached) return;
this.configuration.websocketProvider.on(
"connect",
this.configuration.onConnect,
);
this.configuration.websocketProvider.on("connect", this.forwardConnect);
this.configuration.websocketProvider.on(
"status",
this.configuration.onStatus,
);
this.configuration.websocketProvider.on("status", this.forwardStatus);
this.configuration.websocketProvider.on("open", this.boundOnOpen);
this.configuration.websocketProvider.on("close", this.boundOnClose);
this.configuration.websocketProvider.on(
"close",
this.configuration.onClose,
);
this.configuration.websocketProvider.on("close", this.forwardClose);
this.configuration.websocketProvider.on(
"disconnect",
this.configuration.onDisconnect,
);
this.configuration.websocketProvider.on(
"disconnect",
this.forwardDisconnect,
);
this.configuration.websocketProvider.on(
"destroy",
this.configuration.onDestroy,
);
this.configuration.websocketProvider.on("destroy", this.forwardDestroy);
this.configuration.websocketProvider.attach(this);
this._isAttached = true;
}
permissionDeniedHandler(reason: string) {
this.emit("authenticationFailed", { reason });
this.isAuthenticated = false;
}
authenticatedHandler(scope: string) {
this.isAuthenticated = true;
this.authorizedScope = scope as AuthorizedScope;
this.emit("authenticated", { scope });
}
setAwarenessField(key: string, value: any) {
if (!this.awareness) {
throw new AwarenessError(
`Cannot set awareness field "${key}" to ${JSON.stringify(value)}. You have disabled Awareness for this provider by explicitly passing awareness: null in the provider configuration.`,
);
}
this.awareness.setLocalStateField(key, value);
}
}

View File

@@ -0,0 +1,610 @@
import { WsReadyStates } from "@hocuspocus/common";
import { retry } from "@lifeomic/attempt";
import { createDecoder, readVarString, readVarUint } from "lib0/decoding";
import * as encoding from "lib0/encoding";
import * as time from "lib0/time";
import EventEmitter from "./EventEmitter.ts";
import type { HocuspocusProvider } from "./HocuspocusProvider.ts";
import { IncomingMessage } from "./IncomingMessage.ts";
import { CloseMessage } from "./OutgoingMessages/CloseMessage.ts";
import {
MessageType,
type onAwarenessChangeParameters,
type onAwarenessUpdateParameters,
type onCloseParameters,
type onDisconnectParameters,
type onMaxAttemptsFailedParameters,
type onMessageParameters,
type onOpenParameters,
type onOutgoingMessageParameters,
type onStatusParameters,
WebSocketStatus,
} from "./types.ts";
export type HocuspocusWebSocket = WebSocket & { identifier: string };
export type HocusPocusWebSocket = HocuspocusWebSocket;
export type HocuspocusProviderWebsocketConfiguration = Required<
Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">
> &
Partial<CompleteHocuspocusProviderWebsocketConfiguration>;
export interface CompleteHocuspocusProviderWebsocketConfiguration {
/**
* Whether to connect automatically when creating the provider instance. Default=true
*/
autoConnect: boolean;
/**
* URL of your @hocuspocus/server instance
*/
url: string;
/**
* By default, trailing slashes are removed from the URL. Set this to true
* to preserve trailing slashes if your server configuration requires them.
*/
preserveTrailingSlash: boolean;
/**
* An optional WebSocket polyfill, for example for Node.js
*/
WebSocketPolyfill: any;
/**
* Disconnect when no message is received for the defined amount of milliseconds.
*/
messageReconnectTimeout: number;
/**
* The delay between each attempt in milliseconds. You can provide a factor to have the delay grow exponentially.
*/
delay: number;
/**
* The initialDelay is the amount of time to wait before making the first attempt. This option should typically be 0 since you typically want the first attempt to happen immediately.
*/
initialDelay: number;
/**
* The factor option is used to grow the delay exponentially.
*/
factor: number;
/**
* The maximum number of attempts or 0 if there is no limit on number of attempts.
*/
maxAttempts: number;
/**
* minDelay is used to set a lower bound of delay when jitter is enabled. This property has no effect if jitter is disabled.
*/
minDelay: number;
/**
* The maxDelay option is used to set an upper bound for the delay when factor is enabled. A value of 0 can be provided if there should be no upper bound when calculating delay.
*/
maxDelay: number;
/**
* If jitter is true then the calculated delay will be a random integer value between minDelay and the calculated delay for the current iteration.
*/
jitter: boolean;
/**
* A timeout in milliseconds. If timeout is non-zero then a timer is set using setTimeout. If the timeout is triggered then future attempts will be aborted.
*/
timeout: number;
handleTimeout: (() => Promise<unknown>) | null;
onOpen: (data: onOpenParameters) => void;
onConnect: () => void;
onMessage: (data: onMessageParameters) => void;
onOutgoingMessage: (data: onOutgoingMessageParameters) => void;
onStatus: (data: onStatusParameters) => void;
onDisconnect: (data: onDisconnectParameters) => void;
onClose: (data: onCloseParameters) => void;
onMaxAttemptsFailed: (data: onMaxAttemptsFailedParameters) => void;
onDestroy: () => void;
onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
onAwarenessChange: (data: onAwarenessChangeParameters) => void;
/**
* Map of attached providers keyed by documentName.
*/
providerMap: Map<string, HocuspocusProvider>;
}
export class HocuspocusProviderWebsocket extends EventEmitter {
private static readonly DEDUPLICATABLE_TYPES = new Set([
MessageType.Awareness,
MessageType.QueryAwareness,
]);
private messageQueue: any[] = [];
public configuration: CompleteHocuspocusProviderWebsocketConfiguration = {
url: "",
autoConnect: true,
preserveTrailingSlash: false,
// @ts-expect-error
document: undefined,
WebSocketPolyfill: undefined,
// TODO: this should depend on awareness.outdatedTime
messageReconnectTimeout: 30000,
// 1 second
delay: 1000,
// instant
initialDelay: 0,
// double the delay each time
factor: 2,
// unlimited retries
maxAttempts: 0,
// wait at least 1 second
minDelay: 1000,
// at least every 30 seconds
maxDelay: 30000,
// randomize
jitter: true,
// retry forever
timeout: 0,
onOpen: () => null,
onConnect: () => null,
onMessage: () => null,
onOutgoingMessage: () => null,
onStatus: () => null,
onDisconnect: () => null,
onClose: () => null,
onMaxAttemptsFailed: () => null,
onDestroy: () => null,
onAwarenessUpdate: () => null,
onAwarenessChange: () => null,
handleTimeout: null,
providerMap: new Map(),
};
webSocket: HocusPocusWebSocket | null = null;
webSocketHandlers: { [key: string]: any } = {};
shouldConnect = true;
status = WebSocketStatus.Disconnected;
lastMessageReceived = 0;
identifier = 0;
intervals: any = {
connectionChecker: null,
};
connectionAttempt: {
resolve: (value?: any) => void;
reject: (reason?: any) => void;
} | null = null;
constructor(configuration: HocuspocusProviderWebsocketConfiguration) {
super();
this.setConfiguration(configuration);
this.configuration.WebSocketPolyfill = configuration.WebSocketPolyfill
? configuration.WebSocketPolyfill
: WebSocket;
this.on("open", this.configuration.onOpen);
this.on("open", this.onOpen.bind(this));
this.on("connect", this.configuration.onConnect);
this.on("message", this.configuration.onMessage);
this.on("outgoingMessage", this.configuration.onOutgoingMessage);
this.on("status", this.configuration.onStatus);
this.on("disconnect", this.configuration.onDisconnect);
this.on("close", this.configuration.onClose);
this.on("maxAttemptsFailed", this.configuration.onMaxAttemptsFailed);
this.on("destroy", this.configuration.onDestroy);
this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
this.on("awarenessChange", this.configuration.onAwarenessChange);
this.on("close", this.onClose.bind(this));
this.on("message", this.onMessage.bind(this));
this.intervals.connectionChecker = setInterval(
this.checkConnection.bind(this),
this.configuration.messageReconnectTimeout / 10,
);
if (this.shouldConnect) {
this.connect();
}
}
receivedOnOpenPayload?: Event | undefined = undefined;
async onOpen(event: Event) {
this.status = WebSocketStatus.Connected;
this.emit("status", { status: WebSocketStatus.Connected });
this.cancelWebsocketRetry = undefined;
this.receivedOnOpenPayload = event;
}
attach(provider: HocuspocusProvider) {
const key = provider.effectiveName;
const existing = this.configuration.providerMap.get(key);
if (existing && existing !== provider) {
// Allow replacing a provider that hasn't authenticated (e.g., after auth failure retry)
if (existing.isAuthenticated) {
throw new Error(
`Cannot attach two providers with the same effective name "${key}". ` +
"Use sessionAwareness: true to multiplex providers with the same document name.",
);
}
}
this.configuration.providerMap.set(key, provider);
if (this.status === WebSocketStatus.Disconnected && this.shouldConnect) {
this.connect();
}
if (
this.receivedOnOpenPayload &&
this.status === WebSocketStatus.Connected
) {
provider.onOpen(this.receivedOnOpenPayload);
}
}
detach(provider: HocuspocusProvider) {
const key = provider.effectiveName;
if (this.configuration.providerMap.has(key)) {
provider.send(CloseMessage, {
documentName: key,
});
this.configuration.providerMap.delete(key);
}
}
public setConfiguration(
configuration: Partial<HocuspocusProviderWebsocketConfiguration> = {},
): void {
this.configuration = { ...this.configuration, ...configuration };
if (!this.configuration.autoConnect) {
this.shouldConnect = false;
}
}
cancelWebsocketRetry?: () => void;
async connect() {
if (this.status === WebSocketStatus.Connected) {
return;
}
// Always cancel any previously initiated connection retryer instances
if (this.cancelWebsocketRetry) {
this.cancelWebsocketRetry();
this.cancelWebsocketRetry = undefined;
}
this.receivedOnOpenPayload = undefined;
this.shouldConnect = true;
const abortableRetry = () => {
let cancelAttempt = false;
const retryPromise = retry(this.createWebSocketConnection.bind(this), {
delay: this.configuration.delay,
initialDelay: this.configuration.initialDelay,
factor: this.configuration.factor,
maxAttempts: this.configuration.maxAttempts,
minDelay: this.configuration.minDelay,
maxDelay: this.configuration.maxDelay,
jitter: this.configuration.jitter,
timeout: this.configuration.timeout,
handleTimeout: this.configuration.handleTimeout,
beforeAttempt: (context) => {
if (!this.shouldConnect || cancelAttempt) {
context.abort();
}
},
}).catch((error: any) => {
// If we aborted the connection attempt then don’t report a failure
// ref: https://github.com/lifeomic/attempt/blob/master/src/index.ts#L136
if (error?.code !== "ATTEMPT_ABORTED") {
// connect() is fire-and-forget; rethrowing becomes an unhandled rejection.
this.emit("maxAttemptsFailed", { error });
}
});
return {
retryPromise,
cancelFunc: () => {
cancelAttempt = true;
},
};
};
const { retryPromise, cancelFunc } = abortableRetry();
this.cancelWebsocketRetry = cancelFunc;
return retryPromise;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
attachWebSocketListeners(ws: HocusPocusWebSocket, reject: Function) {
const { identifier } = ws;
const onMessageHandler = (payload: any) => this.emit("message", payload);
const onCloseHandler = (payload: any) =>
this.emit("close", { event: payload });
const onOpenHandler = (payload: any) => this.emit("open", payload);
const onErrorHandler = (err: any) => {
reject(err);
};
this.webSocketHandlers[identifier] = {
message: onMessageHandler,
close: onCloseHandler,
open: onOpenHandler,
error: onErrorHandler,
};
const handlers = this.webSocketHandlers[ws.identifier];
Object.keys(handlers).forEach((name) => {
ws.addEventListener(name, handlers[name]);
});
}
cleanupWebSocket() {
if (!this.webSocket) {
return;
}
const { identifier } = this.webSocket;
const handlers = this.webSocketHandlers[identifier];
Object.keys(handlers).forEach((name) => {
this.webSocket?.removeEventListener(name, handlers[name]);
delete this.webSocketHandlers[identifier];
});
this.webSocket.close();
this.webSocket = null;
}
createWebSocketConnection() {
return new Promise((resolve, reject) => {
if (this.webSocket) {
this.messageQueue = [];
this.cleanupWebSocket();
}
this.lastMessageReceived = 0;
this.identifier += 1;
// Init the WebSocket connection
const ws = new this.configuration.WebSocketPolyfill(this.url);
ws.binaryType = "arraybuffer";
ws.identifier = this.identifier;
this.attachWebSocketListeners(ws, reject);
this.webSocket = ws;
// Reset the status
this.status = WebSocketStatus.Connecting;
this.emit("status", { status: WebSocketStatus.Connecting });
// Store resolve/reject for later use
this.connectionAttempt = {
resolve,
reject,
};
});
}
onMessage(event: MessageEvent) {
this.resolveConnectionAttempt();
this.lastMessageReceived = time.getUnixTime();
const data = new Uint8Array(event.data as ArrayBuffer);
// Check for connection-level Ping message (no document name prefix)
// Ping messages are sent as just the message type byte (length 1)
// We check length to avoid confusing with regular messages that happen to have
// a document name length of 9 as the first byte
if (data.length === 1 && data[0] === MessageType.Ping) {
this.sendPong();
return;
}
const message = new IncomingMessage(data);
const rawKey = message.peekVarString();
const provider = this.configuration.providerMap.get(rawKey);
provider?.onMessage(event);
}
/**
* Send application-level Pong response to server Ping
*/
private sendPong() {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MessageType.Pong);
this.send(encoding.toUint8Array(encoder));
}
resolveConnectionAttempt() {
if (this.connectionAttempt) {
this.connectionAttempt.resolve();
this.connectionAttempt = null;
this.status = WebSocketStatus.Connected;
this.emit("status", { status: WebSocketStatus.Connected });
this.emit("connect");
this.messageQueue.forEach((message) => this.send(message));
this.messageQueue = [];
}
}
stopConnectionAttempt() {
this.connectionAttempt = null;
}
rejectConnectionAttempt(reason?: unknown) {
this.connectionAttempt?.reject(reason);
this.connectionAttempt = null;
}
closeTries = 0;
checkConnection() {
// Don’t check the connection when it’s not even established
if (this.status !== WebSocketStatus.Connected) {
return;
}
// Don’t close the connection while waiting for the first message
if (!this.lastMessageReceived) {
return;
}
// Don’t close the connection when a message was received recently
if (
this.configuration.messageReconnectTimeout >=
time.getUnixTime() - this.lastMessageReceived
) {
return;
}
// No message received in a long time, not even your own
// Awareness updates, which are updated every 15 seconds
// if awareness is enabled.
this.closeTries += 1;
// https://bugs.webkit.org/show_bug.cgi?id=247943
if (this.closeTries > 2) {
this.onClose({
event: {
code: 4408,
reason: "forced",
},
});
this.closeTries = 0;
} else {
this.webSocket?.close();
this.messageQueue = [];
}
}
get serverUrl() {
if (this.configuration.preserveTrailingSlash) {
return this.configuration.url;
}
// By default, ensure that the URL never ends with /
let url = this.configuration.url;
while (url[url.length - 1] === "/") {
url = url.slice(0, url.length - 1);
}
return url;
}
get url() {
return this.serverUrl;
}
disconnect() {
this.shouldConnect = false;
if (this.webSocket === null) {
return;
}
try {
this.webSocket.close();
this.messageQueue = [];
} catch (e) {
console.error(e);
}
}
private parseQueuedMessage(
message: Uint8Array,
): { documentName: string; messageType: number } | null {
try {
const decoder = createDecoder(message);
const documentName = readVarString(decoder);
const messageType = readVarUint(decoder);
return { documentName, messageType };
} catch {
return null;
}
}
private addToQueue(message: any) {
if (message instanceof Uint8Array) {
const parsed = this.parseQueuedMessage(message);
if (
parsed &&
HocuspocusProviderWebsocket.DEDUPLICATABLE_TYPES.has(parsed.messageType)
) {
this.messageQueue = this.messageQueue.filter((queued) => {
if (!(queued instanceof Uint8Array)) return true;
const queuedParsed = this.parseQueuedMessage(queued);
if (!queuedParsed) return true;
return !(
queuedParsed.documentName === parsed.documentName &&
queuedParsed.messageType === parsed.messageType
);
});
}
}
this.messageQueue.push(message);
}
send(message: any) {
if (this.webSocket?.readyState === WsReadyStates.Open) {
this.webSocket.send(message);
} else {
this.addToQueue(message);
}
}
onClose({ event }: onCloseParameters) {
this.closeTries = 0;
this.cleanupWebSocket();
if (this.connectionAttempt) {
// That connection attempt failed. The socket can close without ever
// emitting an error, so pass a reason along – it ends up as the error
// of the `maxAttemptsFailed` event once the retries are exhausted.
this.rejectConnectionAttempt(
new Error(
`WebSocket closed before the connection was established (code: ${event?.code}, reason: ${event?.reason || "none"})`,
),
);
}
// Let’s update the connection status.
this.status = WebSocketStatus.Disconnected;
this.emit("status", { status: WebSocketStatus.Disconnected });
this.emit("disconnect", { event });
// trigger connect if no retry is running and we want to have a connection
if (!this.cancelWebsocketRetry && this.shouldConnect) {
setTimeout(() => {
this.connect();
}, this.configuration.delay);
}
}
destroy() {
this.emit("destroy");
clearInterval(this.intervals.connectionChecker);
// If there is still a connection attempt outstanding then we should stop
// it before calling disconnect, otherwise it will be rejected in the onClose
// handler and trigger a retry
this.stopConnectionAttempt();
this.disconnect();
this.removeAllListeners();
this.cleanupWebSocket();
}
}

View File

@@ -0,0 +1,63 @@
import type { Decoder } from "lib0/decoding";
import {
createDecoder,
peekVarString,
readVarUint,
readVarUint8Array,
readVarString,
} from "lib0/decoding";
import type { Encoder } from "lib0/encoding";
import {
createEncoder,
writeVarUint,
writeVarUint8Array,
writeVarString,
length,
} from "lib0/encoding";
import type { MessageType } from "./types.ts";
export class IncomingMessage {
data: any;
encoder: Encoder;
decoder: Decoder;
constructor(data: any) {
this.data = data;
this.encoder = createEncoder();
this.decoder = createDecoder(new Uint8Array(this.data));
}
peekVarString(): string {
return peekVarString(this.decoder);
}
readVarUint(): MessageType {
return readVarUint(this.decoder);
}
readVarString(): string {
return readVarString(this.decoder);
}
readVarUint8Array() {
return readVarUint8Array(this.decoder);
}
writeVarUint(type: MessageType) {
return writeVarUint(this.encoder, type);
}
writeVarString(string: string) {
return writeVarString(this.encoder, string);
}
writeVarUint8Array(data: Uint8Array) {
return writeVarUint8Array(this.encoder, data);
}
length() {
return length(this.encoder);
}
}

View File

@@ -0,0 +1,136 @@
import { type CloseEvent, readAuthMessage } from "@hocuspocus/common";
import { readVarInt, readVarString } from "lib0/decoding";
import * as awarenessProtocol from "y-protocols/awareness";
import { messageYjsSyncStep2, readSyncMessage } from "y-protocols/sync";
import type { HocuspocusProvider } from "./HocuspocusProvider.ts";
import type { IncomingMessage } from "./IncomingMessage.ts";
import { OutgoingMessage } from "./OutgoingMessage.ts";
import { MessageType } from "./types.ts";
export class MessageReceiver {
message: IncomingMessage;
constructor(message: IncomingMessage) {
this.message = message;
}
public apply(provider: HocuspocusProvider, emitSynced: boolean) {
const { message } = this;
const type = message.readVarUint();
const emptyMessageLength = message.length();
switch (type) {
case MessageType.Sync:
this.applySyncMessage(provider, emitSynced);
break;
case MessageType.Awareness:
this.applyAwarenessMessage(provider);
break;
case MessageType.Auth:
this.applyAuthMessage(provider);
break;
case MessageType.QueryAwareness:
this.applyQueryAwarenessMessage(provider);
break;
case MessageType.Stateless:
provider.receiveStateless(readVarString(message.decoder));
break;
case MessageType.SyncStatus:
this.applySyncStatusMessage(
provider,
readVarInt(message.decoder) === 1,
);
break;
case MessageType.CLOSE: {
// eslint-disable-next-line no-case-declarations
const event: CloseEvent = {
code: 1000,
reason: readVarString(message.decoder),
};
provider.onClose();
provider.configuration.onClose({ event });
provider.forwardClose({ event });
break;
}
default:
console.error(`Can’t apply message of unknown type: ${type}`);
}
// Reply
if (message.length() > emptyMessageLength + 1) {
// length of documentName (considered in emptyMessageLength plus length of yjs sync type, set in applySyncMessage)
// @ts-expect-error
provider.send(OutgoingMessage, { encoder: message.encoder });
}
}
private applySyncMessage(provider: HocuspocusProvider, emitSynced: boolean) {
const { message } = this;
message.writeVarUint(MessageType.Sync);
// Apply update
const syncMessageType = readSyncMessage(
message.decoder,
message.encoder,
provider.document,
provider,
);
// Synced once we receive Step2
if (emitSynced && syncMessageType === messageYjsSyncStep2) {
provider.synced = true;
}
}
applySyncStatusMessage(provider: HocuspocusProvider, applied: boolean) {
if (applied) {
provider.decrementUnsyncedChanges();
}
}
private applyAwarenessMessage(provider: HocuspocusProvider) {
if (!provider.awareness) return;
const { message } = this;
awarenessProtocol.applyAwarenessUpdate(
provider.awareness,
message.readVarUint8Array(),
provider,
);
}
private applyAuthMessage(provider: HocuspocusProvider) {
const { message } = this;
readAuthMessage(
message.decoder,
provider.sendToken.bind(provider),
provider.permissionDeniedHandler.bind(provider),
provider.authenticatedHandler.bind(provider),
);
}
private applyQueryAwarenessMessage(provider: HocuspocusProvider) {
if (!provider.awareness) return;
const { message } = this;
message.writeVarUint(MessageType.Awareness);
message.writeVarUint8Array(
awarenessProtocol.encodeAwarenessUpdate(
provider.awareness,
Array.from(provider.awareness.getStates().keys()),
),
);
}
}

View File

@@ -0,0 +1,22 @@
import type { Encoder } from "lib0/encoding";
import { toUint8Array } from "lib0/encoding";
import type { ConstructableOutgoingMessage } from "./types.ts";
export class MessageSender {
encoder: Encoder;
message: any;
constructor(Message: ConstructableOutgoingMessage, args: any = {}) {
this.message = new Message();
this.encoder = this.message.get(args);
}
create() {
return toUint8Array(this.encoder);
}
send(webSocket: any) {
webSocket?.send(this.create());
}
}

View File

@@ -0,0 +1,27 @@
import type { Encoder } from "lib0/encoding";
import { createEncoder, toUint8Array } from "lib0/encoding";
import type {
MessageType,
OutgoingMessageArguments,
OutgoingMessageInterface,
} from "./types.ts";
export class OutgoingMessage implements OutgoingMessageInterface {
encoder: Encoder;
type?: MessageType;
description?: string;
constructor() {
this.encoder = createEncoder();
}
get(args: Partial<OutgoingMessageArguments>) {
return args.encoder;
}
toUint8Array() {
return toUint8Array(this.encoder);
}
}

View File

@@ -0,0 +1,27 @@
import { writeVarString, writeVarUint } from "lib0/encoding";
import { writeAuthentication } from "@hocuspocus/common";
import type { OutgoingMessageArguments } from "../types.ts";
import { MessageType } from "../types.ts";
import { OutgoingMessage } from "../OutgoingMessage.ts";
import { version } from "../version.ts";
export class AuthenticationMessage extends OutgoingMessage {
type = MessageType.Auth;
description = "Authentication";
get(args: Partial<OutgoingMessageArguments>) {
if (typeof args.token === "undefined") {
throw new Error(
"The authentication message requires `token` as an argument.",
);
}
writeVarString(this.encoder, args.documentName!);
writeVarUint(this.encoder, this.type);
writeAuthentication(this.encoder, args.token);
writeVarString(this.encoder, version);
return this.encoder;
}
}

View File

@@ -0,0 +1,41 @@
import * as encoding from "lib0/encoding";
import { encodeAwarenessUpdate } from "y-protocols/awareness";
import type { OutgoingMessageArguments } from "../types.ts";
import { MessageType } from "../types.ts";
import { OutgoingMessage } from "../OutgoingMessage.ts";
export class AwarenessMessage extends OutgoingMessage {
type = MessageType.Awareness;
description = "Awareness states update";
get(args: Partial<OutgoingMessageArguments>) {
if (typeof args.awareness === "undefined") {
throw new Error(
"The awareness message requires awareness as an argument",
);
}
if (typeof args.clients === "undefined") {
throw new Error("The awareness message requires clients as an argument");
}
encoding.writeVarString(this.encoder, args.documentName!);
encoding.writeVarUint(this.encoder, this.type);
let awarenessUpdate;
if (args.states === undefined) {
awarenessUpdate = encodeAwarenessUpdate(args.awareness, args.clients);
} else {
awarenessUpdate = encodeAwarenessUpdate(
args.awareness,
args.clients,
args.states,
);
}
encoding.writeVarUint8Array(this.encoder, awarenessUpdate);
return this.encoder;
}
}

View File

@@ -0,0 +1,17 @@
import * as encoding from "lib0/encoding";
import type { OutgoingMessageArguments } from "../types.ts";
import { MessageType } from "../types.ts";
import { OutgoingMessage } from "../OutgoingMessage.ts";
export class CloseMessage extends OutgoingMessage {
type = MessageType.CLOSE;
description = "Ask the server to close the connection";
get(args: Partial<OutgoingMessageArguments>) {
encoding.writeVarString(this.encoder, args.documentName!);
encoding.writeVarUint(this.encoder, this.type);
return this.encoder;
}
}

View File

@@ -0,0 +1,17 @@
import * as encoding from "lib0/encoding";
import type { OutgoingMessageArguments } from "../types.ts";
import { MessageType } from "../types.ts";
import { OutgoingMessage } from "../OutgoingMessage.ts";
export class QueryAwarenessMessage extends OutgoingMessage {
type = MessageType.QueryAwareness;
description = "Queries awareness states";
get(args: Partial<OutgoingMessageArguments>) {
encoding.writeVarString(this.encoder, args.documentName!);
encoding.writeVarUint(this.encoder, this.type);
return this.encoder;
}
}

View File

@@ -0,0 +1,18 @@
import { writeVarString, writeVarUint } from "lib0/encoding";
import type { OutgoingMessageArguments } from "../types.ts";
import { MessageType } from "../types.ts";
import { OutgoingMessage } from "../OutgoingMessage.ts";
export class StatelessMessage extends OutgoingMessage {
type = MessageType.Stateless;
description = "A stateless message";
get(args: Partial<OutgoingMessageArguments>) {
writeVarString(this.encoder, args.documentName!);
writeVarUint(this.encoder, this.type);
writeVarString(this.encoder, args.payload ?? "");
return this.encoder;
}
}

Some files were not shown because too many files have changed in this diff Show More