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