snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
68
.coderabbit.yaml
Normal file
68
.coderabbit.yaml
Normal 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
12
.editorconfig
Normal 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
2
.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
**/dist/**
|
||||
**/node_modules/**
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
||||
21
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
22
.github/ISSUE_TEMPLATE/feedback.md
vendored
Normal 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
78
.github/dependabot.yml
vendored
Normal 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
109
.github/workflows/build.yml
vendored
Normal 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
27
.github/workflows/docsearch.yml
vendored
Normal 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
39
.github/workflows/publish.yml
vendored
Normal 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
18
.gitignore
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
}
|
||||
1432
CHANGELOG.md
Normal file
1432
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
LICENSE.md
Normal file
21
LICENSE.md
Normal 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
128
README.md
Normal 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).
|
||||
|
||||
[](https://github.com/ueberdosis/hocuspocus/actions)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/server)
|
||||
[](https://npmcharts.com/compare/@hocuspocus/server?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/server)
|
||||
[](https://discord.gg/WtJ49jGshW)
|
||||
[](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
461
RELEASE_NOTES_V4.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# Hocuspocus v4.0 Release Notes
|
||||
|
||||
Hocuspocus v4 is a major release that brings cross-runtime support, improved type safety, and important bug fixes. This release focuses on making Hocuspocus run beyond Node.js -- on Bun, Deno, Cloudflare Workers, and Node with uWebSockets -- while improving the developer experience with generic Context typing and structured transaction origins.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
A v3 provider can connect to a v4 server, and a v4 provider can connect to a v3 server. The wire protocol remains compatible in both directions:
|
||||
|
||||
- **v3 provider -> v4 server**: The server accepts plain document names (no session routing key), does not require Pong responses, and handles auth messages without a provider version string.
|
||||
- **v4 provider -> v3 server**: The provider defaults to `sessionAwareness: false`, so it sends plain document names. The extra version string in the auth message is ignored by the v3 server as trailing data. The provider does not require server-initiated Ping messages.
|
||||
- **Session awareness caveat**: If `sessionAwareness: true` is explicitly enabled on a v4 provider connecting to a v3 server, the server will treat the composite routing key (`documentName\0sessionId`) as a literal document name, creating unintended documents. Keep `sessionAwareness: false` (the default) when connecting to a v3 server.
|
||||
|
||||
## Highlights
|
||||
|
||||
### Cross-Runtime Support
|
||||
|
||||
Hocuspocus is no longer tied to the Node.js `ws` library. The server now uses [crossws](https://github.com/unjs/crossws), a universal WebSocket adapter, enabling Hocuspocus to run on:
|
||||
|
||||
- **Node.js** (with `ws` or `uWebSockets.js`)
|
||||
- **Bun**
|
||||
- **Deno**
|
||||
- **Cloudflare Workers**
|
||||
|
||||
The built-in `Server` class continues to work as before for Node.js users. For other runtimes, use `Hocuspocus` directly with `handleConnection()`, which now accepts any `WebSocketLike` object and a web-standard `Request`.
|
||||
|
||||
### Generic Context Type
|
||||
|
||||
All core classes and hook payloads now accept a generic `Context` type parameter, enabling end-to-end type safety:
|
||||
|
||||
```typescript
|
||||
interface MyContext {
|
||||
userId: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const server = Server.configure<MyContext>({
|
||||
async onAuthenticate({ context, token }) {
|
||||
// context is typed as MyContext
|
||||
return { userId: '123', permissions: ['read', 'write'] };
|
||||
},
|
||||
async onChange({ context }) {
|
||||
// context.userId is typed as string
|
||||
console.log(context.userId);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The generic defaults to `any`, so existing code without explicit typing continues to work.
|
||||
|
||||
### Ordered Message Processing
|
||||
|
||||
Document updates are now processed sequentially in the order they are received. Previously, concurrent messages could be processed out of order if async hooks were involved. A new internal message queue ensures CRDT updates are applied consistently.
|
||||
|
||||
### Web Standard Request/Headers
|
||||
|
||||
Hook payloads now use the web-standard `Request` and `Headers` objects instead of Node.js `IncomingMessage` and `IncomingHttpHeaders`. This aligns with the cross-runtime goal and provides a consistent API across all environments.
|
||||
|
||||
## New Features
|
||||
|
||||
### Server
|
||||
|
||||
- **Cross-runtime WebSocket support** via `crossws` -- Bun, Deno, Cloudflare Workers, Node/uWebSockets all supported *(non-breaking)*
|
||||
- **Generic `Context` type parameter** on `Hocuspocus`, `Server`, `Extension`, `Connection`, `ClientConnection`, `DirectConnection`, and all hook payloads *(non-breaking -- defaults to `any`)*
|
||||
- **Structured transaction origins** -- new `TransactionOrigin` union type (`ConnectionTransactionOrigin | RedisTransactionOrigin | LocalTransactionOrigin`) with helper functions `isTransactionOrigin()` and `shouldSkipStoreHooks()` *(breaking -- see upgrade guide)*
|
||||
- **`onLoadDocument` now accepts `Uint8Array` returns** -- extensions can return raw Yjs updates instead of constructing a full `Y.Doc`, simplifying storage extensions *(non-breaking)*
|
||||
- **`handleConnection()` returns `ClientConnection`** -- enables programmatic access to the connection lifecycle for custom integrations *(non-breaking)*
|
||||
- **Ordered message processing** -- messages are queued and processed sequentially per connection *(non-breaking)*
|
||||
- **Session awareness** -- the server supports session-aware multiplexing, allowing multiple providers with the same document name on a single WebSocket. Each provider gets a unique `sessionId` routed via a composite key. The server transparently falls back to plain document names for v3 providers *(non-breaking)*
|
||||
- **Auth retry support** -- failed authentication now properly cleans up state, allowing clients to retry without reconnecting *(non-breaking)*
|
||||
- **DirectConnection context** -- `openDirectConnection(documentName, context)` now accepts and propagates a context object *(non-breaking)*
|
||||
- **Store hooks on all changes** -- `onStoreDocument` is now triggered on any document change (not just WebSocket-originated ones), with explicit opt-out via `skipStoreHooks` on `LocalTransactionOrigin` *(non-breaking)*
|
||||
- **Provider version awareness** -- the provider version is available on `Connection.providerVersion` and in hook payloads (`onConnect`, `onAuthenticate`, `connected`), making it easier to introduce protocol changes in a backward-compatible way *(non-breaking)*
|
||||
- **`SkipFurtherHooksError`** -- extensions can throw `SkipFurtherHooksError` (from `@hocuspocus/common`) in `onStoreDocument` to signal that persistence was handled and remaining hooks should be skipped *(non-breaking)*
|
||||
|
||||
### Provider
|
||||
|
||||
- **Session awareness** -- new `sessionAwareness` option (default: `false`). When enabled, the provider embeds a unique `sessionId` in the document name field of every message, enabling multiple providers with the same document name on one WebSocket. Keep disabled when connecting to a v3 server *(non-breaking)*
|
||||
- **Provider version sent during auth** -- the provider now sends its package version to the server in the authentication message. The extra field is safely ignored by v3 servers *(non-breaking)*
|
||||
- **Awareness message deduplication** -- when the WebSocket is not yet open and messages are queued, duplicate awareness messages for the same document are deduplicated, keeping only the latest one *(non-breaking)*
|
||||
- **Application-level Ping/Pong** -- new `MessageType.Ping` (9) and `MessageType.Pong` (10). The provider responds to server Ping messages with Pong, replacing WebSocket-level ping/pong which is not available in all runtimes. The provider works fine without receiving Pings (e.g., when connected to a v3 server) *(non-breaking)*
|
||||
- **`ws` package types removed** -- the provider no longer imports `Event`, `MessageEvent`, or `CloseEvent` from the `ws` package. It uses web-standard types and types from `@hocuspocus/common` instead *(breaking for TypeScript users who relied on `ws` types being re-exported)*
|
||||
- **`CloseEvent` shape simplified** -- the `CloseEvent` passed to `onClose` callbacks no longer includes `target` and `type` fields. Only `code` and `reason` remain *(breaking if your `onClose` handler reads `event.target` or `event.type`)*
|
||||
- **Attach collision detection** -- `HocuspocusProviderWebsocket.attach()` now throws an error if you try to attach two authenticated providers with the same effective name. Previously it silently overwrote the existing provider *(non-breaking for correct usage; may surface existing bugs)*
|
||||
- **Unknown message types handled gracefully** -- unknown message types now log `console.error` instead of throwing. This makes rolling out future protocol additions easier *(non-breaking)*
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Server
|
||||
|
||||
- **Auth state reset on failure** -- when authentication fails, document state is cleaned up so the client can send a new auth message without reconnecting (#944) *(non-breaking)*
|
||||
- **`onLoadDocument` accepts `Uint8Array`** -- the callback now correctly handles both `Y.Doc` and `Uint8Array` returns (#795, #271) *(non-breaking)*
|
||||
- **Close code type check** -- close event codes are now properly validated as numbers (#1062) *(non-breaking)*
|
||||
- **Store hooks reliability** -- `onStoreDocument` now triggers on any document change, preventing accidental data loss when updates lacked a Yjs origin *(non-breaking)*
|
||||
- **`onStoreDocument` payload type** -- the Database extension and Logger extension now correctly type the parameter as `onStoreDocumentPayload` instead of the incorrect `onChangePayload` / `onDisconnectPayload` *(non-breaking)*
|
||||
- **Document name validation** -- empty and whitespace-only document names are now rejected on both WebSocket connections and direct connections *(non-breaking)*
|
||||
- **Store hook retry on failure** -- when `onStoreDocument` hooks throw, the document stays in memory and retries are attempted to avoid data loss *(non-breaking)*
|
||||
- **Graceful shutdown flushes pending stores** -- `Server.destroy()` now immediately executes all pending debounced `onStoreDocument` calls, ensuring documents are persisted before the server exits (even when `unloadImmediately: false`) *(non-breaking)*
|
||||
- **Memory optimization** -- outgoing Yjs update messages are now encoded once and shared across connections instead of being re-created per connection *(non-breaking)*
|
||||
|
||||
### Provider
|
||||
|
||||
- **Unknown message types no longer crash the provider** -- `console.error` instead of `throw`. This makes future protocol additions easier *(non-breaking)*
|
||||
|
||||
## Infrastructure Changes
|
||||
|
||||
- **Package manager**: migrated from npm to **pnpm** workspaces
|
||||
- **Bundler**: migrated from Rollup to **Rolldown**
|
||||
- **SQLite extension**: migrated from `sqlite3` to **better-sqlite3** (synchronous API, actively maintained)
|
||||
- **Node.js requirement**: `>=22` (specified in `@hocuspocus/server`)
|
||||
- **Default timeout**: increased from 30s to 60s
|
||||
- **Lerna**: upgraded to v9 with pnpm as npm client
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Guide: v3 to v4
|
||||
|
||||
## 1. Update Dependencies
|
||||
|
||||
```bash
|
||||
# Install v4
|
||||
npm install @hocuspocus/server@^4.0.0 @hocuspocus/provider@^4.0.0
|
||||
```
|
||||
|
||||
If you use the SQLite extension:
|
||||
|
||||
```bash
|
||||
npm uninstall sqlite3
|
||||
npm install @hocuspocus/extension-sqlite@^4.0.0 better-sqlite3
|
||||
npm install -D @types/better-sqlite3
|
||||
```
|
||||
|
||||
**Node.js requirement**: v4 requires Node.js 22 or later.
|
||||
|
||||
## Server Changes
|
||||
|
||||
### 2. Request and Headers (Breaking)
|
||||
|
||||
All hook payloads now use web-standard `Request` and `Headers` instead of Node.js `IncomingMessage` and `IncomingHttpHeaders`.
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
async onAuthenticate({ request, requestHeaders }) {
|
||||
const token = requestHeaders['authorization'];
|
||||
const ip = requestHeaders['x-forwarded-for'];
|
||||
const url = request.url;
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
async onAuthenticate({ request, requestHeaders }) {
|
||||
const token = requestHeaders.get('authorization');
|
||||
const ip = requestHeaders.get('x-forwarded-for');
|
||||
const url = request.url;
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences:**
|
||||
- `requestHeaders['key']` becomes `requestHeaders.get('key')`
|
||||
- `request.socket.remoteAddress` is no longer available -- use `x-forwarded-for` or `x-real-ip` headers from your reverse proxy
|
||||
- `request` is now a web `Request` object, not Node.js `IncomingMessage`
|
||||
|
||||
**Note:** The `onUpgrade` and `onRequest` hooks still use Node.js `IncomingMessage`/`ServerResponse` since they operate at the HTTP level before the WebSocket upgrade.
|
||||
|
||||
### 3. `onStoreDocument` Payload (Breaking)
|
||||
|
||||
The `onStoreDocument` and `afterStoreDocument` payloads have been restructured. Several fields that were tied to a specific connection have been removed, since store hooks can now be triggered by non-connection sources.
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
async onStoreDocument({
|
||||
context,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
socketId,
|
||||
transactionOrigin,
|
||||
document,
|
||||
documentName,
|
||||
clientsCount,
|
||||
instance,
|
||||
}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
async onStoreDocument({
|
||||
lastContext, // was: context
|
||||
lastTransactionOrigin, // was: transactionOrigin
|
||||
document,
|
||||
documentName,
|
||||
clientsCount,
|
||||
instance,
|
||||
// removed: requestHeaders, requestParameters, socketId
|
||||
}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Migration:**
|
||||
- `context` → `lastContext`
|
||||
- `transactionOrigin` → `lastTransactionOrigin`
|
||||
- `requestHeaders`, `requestParameters`, `socketId` -- removed. If you need these, access them from the `lastContext`. Note that they contain the context of the last connection that triggered the hook.
|
||||
|
||||
### 4. `onAwarenessUpdate` Payload (Breaking)
|
||||
|
||||
The `onAwarenessUpdate` payload has been simplified. Connection-specific fields are removed and replaced with the source of the update.
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
async onAwarenessUpdate({
|
||||
context,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
socketId,
|
||||
document,
|
||||
documentName,
|
||||
added, updated, removed, states,
|
||||
}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
async onAwarenessUpdate({
|
||||
transactionOrigin, // new: structured origin
|
||||
connection, // new: optional, the connection that triggered the update
|
||||
document,
|
||||
documentName,
|
||||
added, updated, removed,
|
||||
awareness, // new: the Awareness instance
|
||||
states,
|
||||
// removed: context, requestHeaders, requestParameters, socketId
|
||||
}) {
|
||||
// Access context through the connection if needed
|
||||
const context = connection?.context;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. WebSocket Type Changes (Breaking)
|
||||
|
||||
If your code references the `WebSocket` type from the `ws` package, update to `WebSocketLike`:
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
// In your code
|
||||
const ws: WebSocket = connection.webSocket;
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
import type { WebSocketLike } from '@hocuspocus/server';
|
||||
|
||||
// In your code
|
||||
const ws: WebSocketLike = connection.webSocket;
|
||||
```
|
||||
|
||||
The `WebSocketLike` interface is minimal:
|
||||
|
||||
```typescript
|
||||
interface WebSocketLike {
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
readyState: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Server Constructor (Breaking)
|
||||
|
||||
WebSocket options are now passed inside the configuration object instead of as a separate parameter.
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
import { Server } from '@hocuspocus/server';
|
||||
|
||||
const server = new Server(
|
||||
{ port: 8080, extensions: [...] },
|
||||
{ maxPayload: 1024 * 1024 } // ws options as 2nd arg
|
||||
);
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
import { Server } from '@hocuspocus/server';
|
||||
|
||||
const server = new Server({
|
||||
port: 8080,
|
||||
extensions: [...],
|
||||
websocketOptions: { maxPayload: 1024 * 1024 },
|
||||
});
|
||||
```
|
||||
|
||||
`Server.configure()` still works the same way -- just move `websocketOptions` into the config.
|
||||
|
||||
### 7. Transaction Origin Changes (Breaking)
|
||||
|
||||
If you inspect `transactionOrigin` in hooks (e.g., in `onChange`), the format has changed from raw values to structured objects.
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
async onChange({ transactionOrigin }) {
|
||||
if (transactionOrigin === '__hocuspocus__redis__origin__') {
|
||||
// came from Redis
|
||||
}
|
||||
if (transactionOrigin instanceof Connection) {
|
||||
// came from a client connection
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
import { isTransactionOrigin, shouldSkipStoreHooks } from '@hocuspocus/server';
|
||||
|
||||
async onChange({ transactionOrigin }) {
|
||||
if (isTransactionOrigin(transactionOrigin)) {
|
||||
switch (transactionOrigin.source) {
|
||||
case 'redis':
|
||||
// came from Redis
|
||||
break;
|
||||
case 'connection':
|
||||
// came from a client connection
|
||||
// transactionOrigin.connection is available
|
||||
break;
|
||||
case 'local':
|
||||
// came from server-side code (e.g., DirectConnection)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. SQLite Extension (Breaking)
|
||||
|
||||
The SQLite extension has been migrated from the unmaintained `sqlite3` package to `better-sqlite3`.
|
||||
|
||||
**What you need to do:**
|
||||
1. Replace the `sqlite3` dependency with `better-sqlite3`
|
||||
2. No changes to your Hocuspocus configuration -- the extension API is the same
|
||||
3. Existing SQLite database files are fully compatible (no data migration needed)
|
||||
|
||||
If you wrote a **custom schema** for the SQLite extension, note that `better-sqlite3` uses named parameters without the `$` prefix:
|
||||
- `$name` → `name`
|
||||
- `$data` → `data`
|
||||
|
||||
### 9. Custom `handleConnection` Integrations (Breaking)
|
||||
|
||||
If you call `handleConnection()` directly (e.g., for Express/Koa integration), the signature has changed:
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
import { IncomingMessage } from 'http';
|
||||
|
||||
wss.on('connection', (ws: WebSocket, request: IncomingMessage) => {
|
||||
hocuspocus.handleConnection(ws, request, context);
|
||||
});
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
wss.on('connection', (ws, request: Request) => {
|
||||
const clientConnection = hocuspocus.handleConnection(ws, request, context);
|
||||
// clientConnection is now returned for programmatic access
|
||||
});
|
||||
```
|
||||
|
||||
**Key changes:**
|
||||
- `request` must be a web-standard `Request` (not `IncomingMessage`)
|
||||
- The method now returns a `ClientConnection` instance
|
||||
- The WebSocket no longer needs to be from the `ws` package -- any `WebSocketLike` works
|
||||
- You are responsible for calling `clientConnection.handleMessage(data)` and `clientConnection.handleClose(event)` if you're not using the built-in `Server` class
|
||||
|
||||
### 10. Timeout Change (Non-Breaking)
|
||||
|
||||
The default connection timeout has increased from 30 seconds to 60 seconds. If you relied on the old default, you can restore it:
|
||||
|
||||
```typescript
|
||||
const server = Server.configure({
|
||||
timeout: 30_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Provider Changes
|
||||
|
||||
### 11. `CloseEvent` Shape (Breaking)
|
||||
|
||||
The `CloseEvent` passed to `onClose` callbacks no longer includes `target` and `type` fields. Only `code` and `reason` remain.
|
||||
|
||||
#### Before (v3)
|
||||
|
||||
```typescript
|
||||
onClose({ event }) {
|
||||
console.log(event.code, event.reason, event.target, event.type);
|
||||
}
|
||||
```
|
||||
|
||||
#### After (v4)
|
||||
|
||||
```typescript
|
||||
onClose({ event }) {
|
||||
console.log(event.code, event.reason);
|
||||
// event.target and event.type are no longer available
|
||||
}
|
||||
```
|
||||
|
||||
### 12. `ws` Package Types Removed (Breaking -- TypeScript Only)
|
||||
|
||||
The provider no longer imports or re-exports `Event`, `MessageEvent`, or `CloseEvent` from the `ws` package. If your TypeScript code relied on these types being available through the provider, import them from `@hocuspocus/common` or use web-standard types instead.
|
||||
|
||||
### 13. Session Awareness (Non-Breaking)
|
||||
|
||||
The provider has a new `sessionAwareness` option (default: `false`). When enabled, it embeds a unique `sessionId` in the document name field of every message, allowing multiple providers with the same document name on a single WebSocket.
|
||||
|
||||
No action is needed -- the default is `false`, which preserves v3 behavior. Enable it only when connecting to a v4 server and you need session multiplexing:
|
||||
|
||||
```typescript
|
||||
const provider = new HocuspocusProvider({
|
||||
url: 'ws://localhost:1234',
|
||||
name: 'my-document',
|
||||
sessionAwareness: true, // opt-in for v4 server multiplexing
|
||||
});
|
||||
```
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
### Server
|
||||
- [ ] Update to Node.js 22+
|
||||
- [ ] Update all `@hocuspocus/*` packages to v4
|
||||
- [ ] Replace `requestHeaders['key']` with `requestHeaders.get('key')` in all hooks
|
||||
- [ ] Replace `request.socket.remoteAddress` with proxy headers (`x-forwarded-for`)
|
||||
- [ ] Update `onStoreDocument` handlers: `context` -> `lastContext`, remove `requestHeaders`/`requestParameters`/`socketId`
|
||||
- [ ] Update `onAwarenessUpdate` handlers if used
|
||||
- [ ] Replace `WebSocket` type imports from `ws` with `WebSocketLike` from `@hocuspocus/server`
|
||||
- [ ] Move `websocketOptions` into the `Server` configuration object
|
||||
- [ ] Update transaction origin checks to use `isTransactionOrigin()` and `.source`
|
||||
- [ ] If using SQLite extension: replace `sqlite3` with `better-sqlite3`
|
||||
- [ ] If using custom `handleConnection`: update to new signature and `Request` type
|
||||
|
||||
### Provider
|
||||
- [ ] Update `@hocuspocus/provider` to v4
|
||||
- [ ] Remove references to `event.target` / `event.type` in `onClose` handlers
|
||||
- [ ] Update any TypeScript imports that relied on `ws` types from the provider
|
||||
- [ ] Test your extensions and hooks thoroughly
|
||||
8
babel.config.cjs
Normal file
8
babel.config.cjs
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-transform-class-properties',
|
||||
],
|
||||
}
|
||||
18
biome.json
Normal file
18
biome.json
Normal 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
7
docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: "redis:6-alpine"
|
||||
ports:
|
||||
- "6379:6379"
|
||||
37
docsearch.config.json
Normal file
37
docsearch.config.json
Normal 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
13
lerna.json
Normal 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
79
package.json
Normal 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
1273
packages/cli/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/cli/README.md
Normal file
18
packages/cli/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @hocuspocus/cli
|
||||
[](https://www.npmjs.com/package/@hocuspocus/cli)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/cli)
|
||||
[](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
35
packages/cli/package.json
Normal 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
150
packages/cli/src/index.js
Executable 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
1195
packages/common/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/common/README.md
Normal file
14
packages/common/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @hocuspocus/common
|
||||
[](https://www.npmjs.com/package/@hocuspocus/common)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/common)
|
||||
[](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).
|
||||
38
packages/common/package.json
Normal file
38
packages/common/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
packages/common/src/CloseEvents.ts
Normal file
49
packages/common/src/CloseEvents.ts
Normal 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",
|
||||
};
|
||||
6
packages/common/src/SkipFurtherHooksError.ts
Normal file
6
packages/common/src/SkipFurtherHooksError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class SkipFurtherHooksError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? "Further hooks skipped");
|
||||
this.name = "SkipFurtherHooksError";
|
||||
}
|
||||
}
|
||||
62
packages/common/src/auth.ts
Normal file
62
packages/common/src/auth.ts
Normal 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:
|
||||
}
|
||||
};
|
||||
10
packages/common/src/awarenessStatesToArray.ts
Normal file
10
packages/common/src/awarenessStatesToArray.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
};
|
||||
6
packages/common/src/index.ts
Normal file
6
packages/common/src/index.ts
Normal 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";
|
||||
11
packages/common/src/routingKey.ts
Normal file
11
packages/common/src/routingKey.ts
Normal 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) };
|
||||
}
|
||||
10
packages/common/src/types.ts
Normal file
10
packages/common/src/types.ts
Normal 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,
|
||||
}
|
||||
1260
packages/extension-database/CHANGELOG.md
Normal file
1260
packages/extension-database/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
51
packages/extension-database/README.md
Normal file
51
packages/extension-database/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @hocuspocus/extension-database
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-database)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-database)
|
||||
[](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).
|
||||
42
packages/extension-database/package.json
Normal file
42
packages/extension-database/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
62
packages/extension-database/src/Database.ts
Normal file
62
packages/extension-database/src/Database.ts
Normal 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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
1
packages/extension-database/src/index.ts
Normal file
1
packages/extension-database/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Database.ts";
|
||||
1732
packages/extension-logger/CHANGELOG.md
Normal file
1732
packages/extension-logger/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
56
packages/extension-logger/README.md
Normal file
56
packages/extension-logger/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @hocuspocus/extension-logger
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-logger)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-logger)
|
||||
[](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).
|
||||
41
packages/extension-logger/package.json
Normal file
41
packages/extension-logger/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
164
packages/extension-logger/src/Logger.ts
Normal file
164
packages/extension-logger/src/Logger.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
packages/extension-logger/src/index.ts
Normal file
1
packages/extension-logger/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Logger.ts";
|
||||
1683
packages/extension-redis/CHANGELOG.md
Normal file
1683
packages/extension-redis/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
76
packages/extension-redis/README.md
Normal file
76
packages/extension-redis/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# @hocuspocus/extension-redis
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-redis)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-redis)
|
||||
[](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).
|
||||
48
packages/extension-redis/package.json
Normal file
48
packages/extension-redis/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
839
packages/extension-redis/src/Redis.ts
Normal file
839
packages/extension-redis/src/Redis.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
packages/extension-redis/src/index.ts
Normal file
1
packages/extension-redis/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Redis.ts";
|
||||
337
packages/extension-s3/CHANGELOG.md
Normal file
337
packages/extension-s3/CHANGELOG.md
Normal 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
|
||||
171
packages/extension-s3/README.md
Normal file
171
packages/extension-s3/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# @hocuspocus/extension-s3
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-s3)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-s3)
|
||||
[](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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
42
packages/extension-s3/package.json
Normal file
42
packages/extension-s3/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
197
packages/extension-s3/src/S3.ts
Normal file
197
packages/extension-s3/src/S3.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/extension-s3/src/index.ts
Normal file
1
packages/extension-s3/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./S3.ts";
|
||||
1273
packages/extension-sqlite/CHANGELOG.md
Normal file
1273
packages/extension-sqlite/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
55
packages/extension-sqlite/README.md
Normal file
55
packages/extension-sqlite/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# @hocuspocus/extension-sqlite
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-sqlite)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-sqlite)
|
||||
[](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).
|
||||
44
packages/extension-sqlite/package.json
Normal file
44
packages/extension-sqlite/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
81
packages/extension-sqlite/src/SQLite.ts
Normal file
81
packages/extension-sqlite/src/SQLite.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/extension-sqlite/src/index.ts
Normal file
1
packages/extension-sqlite/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SQLite.ts";
|
||||
1593
packages/extension-throttle/CHANGELOG.md
Normal file
1593
packages/extension-throttle/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/extension-throttle/README.md
Normal file
48
packages/extension-throttle/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @hocuspocus/extension-throttle
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
|
||||
[](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).
|
||||
40
packages/extension-throttle/package.json
Normal file
40
packages/extension-throttle/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
126
packages/extension-throttle/src/index.ts
Normal file
126
packages/extension-throttle/src/index.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1666
packages/extension-webhook/CHANGELOG.md
Normal file
1666
packages/extension-webhook/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/extension-webhook/README.md
Normal file
44
packages/extension-webhook/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @hocuspocus/extension-webhook
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
|
||||
[](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).
|
||||
47
packages/extension-webhook/package.json
Normal file
47
packages/extension-webhook/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
227
packages/extension-webhook/src/index.ts
Normal file
227
packages/extension-webhook/src/index.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
packages/provider-react/CHANGELOG.md
Normal file
138
packages/provider-react/CHANGELOG.md
Normal 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))
|
||||
70
packages/provider-react/README.md
Normal file
70
packages/provider-react/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# @hocuspocus/provider-react
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider-react)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider-react)
|
||||
[](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).
|
||||
47
packages/provider-react/package.json
Normal file
47
packages/provider-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
168
packages/provider-react/src/HocuspocusRoom.tsx
Normal file
168
packages/provider-react/src/HocuspocusRoom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
packages/provider-react/src/context.ts
Normal file
18
packages/provider-react/src/context.ts
Normal 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);
|
||||
5
packages/provider-react/src/hooks/index.ts
Normal file
5
packages/provider-react/src/hooks/index.ts
Normal 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";
|
||||
89
packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Normal file
89
packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
52
packages/provider-react/src/hooks/useHocuspocusEvent.ts
Normal file
52
packages/provider-react/src/hooks/useHocuspocusEvent.ts
Normal 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]);
|
||||
}
|
||||
37
packages/provider-react/src/hooks/useHocuspocusProvider.ts
Normal file
37
packages/provider-react/src/hooks/useHocuspocusProvider.ts
Normal 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;
|
||||
}
|
||||
51
packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts
Normal file
51
packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts
Normal 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);
|
||||
}
|
||||
29
packages/provider-react/src/index.ts
Normal file
29
packages/provider-react/src/index.ts
Normal 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";
|
||||
155
packages/provider-react/src/types.ts
Normal file
155
packages/provider-react/src/types.ts
Normal 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;
|
||||
}
|
||||
6
packages/provider-react/tsconfig.json
Normal file
6
packages/provider-react/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
1492
packages/provider/CHANGELOG.md
Normal file
1492
packages/provider/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
71
packages/provider/README.md
Normal file
71
packages/provider/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# @hocuspocus/provider
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider)
|
||||
[](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).
|
||||
47
packages/provider/package.json
Normal file
47
packages/provider/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
packages/provider/src/EventEmitter.ts
Normal file
44
packages/provider/src/EventEmitter.ts
Normal 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 = {};
|
||||
}
|
||||
}
|
||||
760
packages/provider/src/HocuspocusProvider.ts
Normal file
760
packages/provider/src/HocuspocusProvider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
610
packages/provider/src/HocuspocusProviderWebsocket.ts
Normal file
610
packages/provider/src/HocuspocusProviderWebsocket.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
63
packages/provider/src/IncomingMessage.ts
Normal file
63
packages/provider/src/IncomingMessage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
packages/provider/src/MessageReceiver.ts
Normal file
136
packages/provider/src/MessageReceiver.ts
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
packages/provider/src/MessageSender.ts
Normal file
22
packages/provider/src/MessageSender.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
27
packages/provider/src/OutgoingMessage.ts
Normal file
27
packages/provider/src/OutgoingMessage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
41
packages/provider/src/OutgoingMessages/AwarenessMessage.ts
Normal file
41
packages/provider/src/OutgoingMessages/AwarenessMessage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
packages/provider/src/OutgoingMessages/CloseMessage.ts
Normal file
17
packages/provider/src/OutgoingMessages/CloseMessage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
packages/provider/src/OutgoingMessages/StatelessMessage.ts
Normal file
18
packages/provider/src/OutgoingMessages/StatelessMessage.ts
Normal 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
Reference in New Issue
Block a user