├── .editorconfig ├── .eslintignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── feedback.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── deploy.yml │ ├── docsearch.yml │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── babel.config.cjs ├── biome.json ├── docker-compose.yml ├── docs ├── CHANGELOG.md ├── contributing.md ├── getting-started.md ├── guides │ ├── auth.md │ ├── awareness.md │ ├── collaborative-editing.md │ ├── custom-extensions.md │ ├── multi-subdocuments.md │ ├── persistence.md │ └── scalability.md ├── introduction.md ├── license.md ├── links.yaml ├── provider │ ├── configuration.md │ ├── events.md │ ├── examples.md │ ├── installation.md │ └── introduction.md ├── server │ ├── cloud.md │ ├── configuration.md │ ├── examples.md │ ├── extensions.md │ ├── extensions │ │ ├── database.md │ │ ├── logger.md │ │ ├── redis.md │ │ ├── sqlite.md │ │ ├── throttle.md │ │ └── webhook.md │ ├── hooks.md │ ├── methods.md │ └── usage.md ├── sponsor.md └── upgrade.md ├── docsearch.config.json ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── cli │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ └── index.js ├── common │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ └── src │ │ ├── CloseEvents.ts │ │ ├── auth.ts │ │ ├── awarenessStatesToArray.ts │ │ ├── index.ts │ │ └── types.ts ├── extension-database │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ └── src │ │ ├── Database.ts │ │ └── index.ts ├── extension-logger │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ ├── Logger.ts │ │ └── index.ts ├── extension-redis │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ ├── Redis.ts │ │ └── index.ts ├── extension-sqlite │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ └── src │ │ ├── SQLite.ts │ │ └── index.ts ├── extension-throttle │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ └── index.ts ├── extension-webhook │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ └── index.ts ├── provider │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ ├── EventEmitter.ts │ │ ├── HocuspocusProvider.ts │ │ ├── HocuspocusProviderWebsocket.ts │ │ ├── IncomingMessage.ts │ │ ├── MessageReceiver.ts │ │ ├── MessageSender.ts │ │ ├── OutgoingMessage.ts │ │ ├── OutgoingMessages │ │ ├── AuthenticationMessage.ts │ │ ├── AwarenessMessage.ts │ │ ├── CloseMessage.ts │ │ ├── QueryAwarenessMessage.ts │ │ ├── StatelessMessage.ts │ │ ├── SyncStepOneMessage.ts │ │ ├── SyncStepTwoMessage.ts │ │ └── UpdateMessage.ts │ │ ├── index.ts │ │ └── types.ts ├── server │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ └── src │ │ ├── ClientConnection.ts │ │ ├── Connection.ts │ │ ├── DirectConnection.ts │ │ ├── Document.ts │ │ ├── Hocuspocus.ts │ │ ├── IncomingMessage.ts │ │ ├── MessageReceiver.ts │ │ ├── OutgoingMessage.ts │ │ ├── Server.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── util │ │ ├── debounce.ts │ │ └── getParameters.ts └── transformer │ ├── CHANGELOG.md │ ├── package.json │ └── src │ ├── Prosemirror.ts │ ├── Tiptap.ts │ ├── index.ts │ └── types.ts ├── playground ├── README.md ├── backend │ ├── .gitignore │ ├── CHANGELOG.md │ ├── package.json │ └── src │ │ ├── default.ts │ │ ├── express.ts │ │ ├── koa.ts │ │ ├── load-document.ts │ │ ├── redis.ts │ │ ├── slow.ts │ │ ├── tiptapcollab.ts │ │ └── webhook.ts └── frontend │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── app │ ├── SocketContext.ts │ ├── articles │ │ ├── [slug] │ │ │ ├── ArticleEditor.tsx │ │ │ ├── CollaborationStatus.tsx │ │ │ ├── CollaborativeEditor.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg │ └── tsconfig.json ├── rollup.config.js ├── tests ├── CHANGELOG.md ├── extension-database │ └── fetch.ts ├── extension-logger │ └── onListen.ts ├── extension-redis │ ├── onAwarenessChange.ts │ ├── onChange.ts │ ├── onStateless.ts │ └── onStoreDocument.ts ├── extension-throttle │ ├── banning.ts │ └── configuration.ts ├── package.json ├── provider │ ├── hasUnsyncedChanges.ts │ ├── observe.ts │ ├── observeDeep.ts │ ├── onAuthenticated.ts │ ├── onAuthenticationFailed.ts │ ├── onAwarenessChange.ts │ ├── onAwarenessUpdate.ts │ ├── onClose.ts │ ├── onConnect.ts │ ├── onDisconnect.ts │ ├── onMessage.ts │ ├── onOpen.ts │ ├── onStateless.ts │ └── onSynced.ts ├── providerwebsocket │ └── configuration.ts ├── server │ ├── address.ts │ ├── afterLoadDocument.ts │ ├── afterStoreDocument.ts │ ├── afterUnloadDocument.ts │ ├── beforeBroadcastStateless.ts │ ├── beforeHandleMessage.ts │ ├── beforeSync.ts │ ├── closeConnections.ts │ ├── getConnectionsCount.ts │ ├── getDocumentsCount.ts │ ├── listen.ts │ ├── onAuthenticate.ts │ ├── onAwarenessUpdate.ts │ ├── onChange.ts │ ├── onClose.ts │ ├── onConfigure.ts │ ├── onConnect.ts │ ├── onDestroy.ts │ ├── onDisconnect.ts │ ├── onListen.ts │ ├── onLoadDocument.ts │ ├── onRequest.ts │ ├── onStateless.ts │ ├── onStoreDocument.ts │ ├── onUpgrade.ts │ ├── openDirectConnection.ts │ └── websocketError.ts ├── transformer │ └── TiptapTransformer.ts └── utils │ ├── createDirectory.ts │ ├── flushRedis.ts │ ├── index.ts │ ├── newHocuspocus.ts │ ├── newHocuspocusProvider.ts │ ├── newHocuspocusProviderWebsocket.ts │ ├── randomInteger.ts │ ├── redisConnectionSettings.ts │ ├── removeDirectory.ts │ ├── retryableAssertion.ts │ └── sleep.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | max_line_length = 100 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/** 2 | **/node_modules/** -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve Hocuspocus 4 | url: https://github.com/ueberdosis/hocuspocus/issues/new 5 | title: '' 6 | labels: 7 | - bug 8 | assignees: 9 | - janthurau 10 | --- 11 | 12 | **Description** 13 | A clear and concise description of what the bug is. 14 | 15 | **Steps to reproduce the bug** 16 | Steps to reproduce the behavior: 17 | 1. Go to … 18 | 2. Type in … 19 | 3. Click on … 20 | 4. See error message 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshot, video, or GIF** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Environment?** 29 | - operating system: 30 | - browser: 31 | - mobile/desktop: 32 | - Hocuspocus version: 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discuss hocuspocus on GitHub 4 | url: https://github.com/ueberdosis/hocuspocus/discussions/new 5 | about: Help, discussion about best practices, or any other conversation that would benefit from being searchable 6 | - name: Join the hocuspocus Discord server 7 | url: https://discord.gg/WtJ49jGshW 8 | about: Ccasual chit-chat with others using hocuspocus 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for Hocuspocus 4 | title: '' 5 | labels: 6 | - feature request 7 | assignees: 8 | - janthurau 9 | --- 10 | 11 | **The problem I am facing** 12 | A clear and concise description of what the problem is. For example: I’m always frustrated when … 13 | 14 | **The solution I would like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Alternatives I have considered** 18 | A clear and concise description of any alternative solutions or features you have considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots here. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feedback on the documentation 3 | about: Share what we need to explain better 4 | url: https://github.com/ueberdosis/hocuspocus/issues/new 5 | title: '' 6 | labels: 7 | - documentation 8 | assignees: 9 | - janthurau 10 | --- 11 | 12 | **Part of the documentation?** 13 | I’ve read the following page of the documentation … 14 | 15 | **Really helpful parts** 16 | I think this part is really good: … 17 | 18 | **Hard to understand, missing or misleading** 19 | But you really need to improve … 20 | 21 | **Additional context** 22 | Add any other context or screenshots here. 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot creates pull requests to keep your dependencies secure and up-to-date. 2 | # Documentation: https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | 7 | - package-ecosystem: 'github-actions' 8 | directory: '/' 9 | open-pull-requests-limit: 10 10 | schedule: 11 | interval: 'weekly' 12 | day: 'monday' 13 | 14 | - package-ecosystem: "npm" 15 | directory: "packages/cli" 16 | schedule: 17 | interval: 'weekly' 18 | day: 'monday' 19 | 20 | - package-ecosystem: "npm" 21 | directory: "packages/common" 22 | schedule: 23 | interval: 'weekly' 24 | day: 'monday' 25 | 26 | - package-ecosystem: "npm" 27 | directory: "packages/extension-database" 28 | schedule: 29 | interval: 'weekly' 30 | day: 'monday' 31 | 32 | - package-ecosystem: "npm" 33 | directory: "packages/extension-logger" 34 | schedule: 35 | interval: 'weekly' 36 | day: 'monday' 37 | 38 | - package-ecosystem: "npm" 39 | directory: "packages/extension-redis" 40 | schedule: 41 | interval: 'weekly' 42 | day: 'monday' 43 | 44 | - package-ecosystem: "npm" 45 | directory: "packages/extension-sqlite" 46 | schedule: 47 | interval: 'weekly' 48 | day: 'monday' 49 | 50 | - package-ecosystem: "npm" 51 | directory: "packages/extension-throttle" 52 | schedule: 53 | interval: 'weekly' 54 | day: 'monday' 55 | 56 | - package-ecosystem: "npm" 57 | directory: "packages/extension-webhook" 58 | schedule: 59 | interval: 'weekly' 60 | day: 'monday' 61 | 62 | - package-ecosystem: "npm" 63 | directory: "packages/provider" 64 | schedule: 65 | interval: 'weekly' 66 | day: 'monday' 67 | 68 | - package-ecosystem: "npm" 69 | directory: "packages/server" 70 | schedule: 71 | interval: 'weekly' 72 | day: 'monday' 73 | 74 | - package-ecosystem: "npm" 75 | directory: "packages/transformer" 76 | schedule: 77 | interval: 'weekly' 78 | day: 'monday' 79 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Automate, customize, and execute your software development workflows right in your repository with GitHub Actions. 2 | # Documentation: https://docs.github.com/en/actions 3 | 4 | name: deploy 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | 13 | deploy: 14 | runs-on: ubuntu-latest 15 | 16 | if: github.ref == 'refs/heads/main' 17 | 18 | steps: 19 | 20 | - name: Update the documentation 21 | run: curl ${{ secrets.TRIGGER_DEPLOYMENT }} 22 | -------------------------------------------------------------------------------- /.github/workflows/docsearch.yml: -------------------------------------------------------------------------------- 1 | # Automate, customize, and execute your software development workflows right in your repository with GitHub Actions. 2 | # Documentation: https://docs.github.com/en/actions 3 | 4 | name: docsearch 5 | 6 | on: 7 | workflow_dispatch: 8 | schedule: 9 | - cron: '5 1 * * *' 10 | 11 | jobs: 12 | 13 | docsearch: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Run DocSearch Scraper 19 | shell: bash 20 | run: | 21 | docker run \ 22 | -e TYPESENSE_API_KEY=${{ secrets.TYPESENSE_API_KEY }} \ 23 | -e TYPESENSE_HOST="${{ secrets.TYPESENSE_HOST }}" \ 24 | -e TYPESENSE_PORT="${{ secrets.TYPESENSE_PORT }}" \ 25 | -e TYPESENSE_PROTOCOL="${{ secrets.TYPESENSE_PROTOCOL }}" \ 26 | -e CONFIG="$(cat docsearch.config.json | jq -r tostring)" \ 27 | typesense/docsearch-scraper 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish to NPM 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | publish-npm: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | registry-url: https://registry.npmjs.org/ 20 | 21 | - name: Set up npmrc 22 | id: setup-npmrc 23 | run: echo "@tiptap-cloud:registry=https://registry.tiptap.dev/" >> ~/.npmrc && echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_PRIVATE_REGISTRY_NPM_TOKEN }}" >> ~/.npmrc 24 | 25 | - run: npm ci 26 | 27 | - run: npm run publish 28 | if: "!contains(github.ref, '-rc.')" 29 | env: 30 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 31 | 32 | - run: npm run publish:pre 33 | if: "contains(github.ref, '-rc.')" 34 | env: 35 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .cache 3 | .DS_Store 4 | .env 5 | .env.* 6 | .temp 7 | dist 8 | node_modules 9 | .idea 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | /database 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023, Tiptap GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | ], 5 | plugins: [ 6 | '@babel/plugin-transform-class-properties', 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "vcs": { 3 | "enabled": true, 4 | "clientKind": "git", 5 | "useIgnoreFile": true 6 | }, 7 | "linter": { 8 | "rules": { 9 | "suspicious": { 10 | "noAsyncPromiseExecutor": "off" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | redis: 5 | image: "redis:6-alpine" 6 | ports: 7 | - "6379:6379" 8 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## @hocuspocus/server@1.0.0-alpha.59 4 | * fix: catch exceptions thrown in hooks, and fail silently 5 | 6 | ## @hocuspocus/server@1.0.0-alpha.58 7 | * fix: make sure awareness states aren’t shared with users connected to other URLs 8 | 9 | ## @hocuspocus/server 1.0.0-alpha.53 10 | * fix: queue messages until all onConnect hooks are resolved (https://github.com/ueberdosis/hocuspocus/issues/92) 11 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | The two code examples below show a working example of the backend _and_ frontend to sync an array 4 | with multiple users. We have also added some examples in the [playground folder of the 5 | repo](https://github.com/ueberdosis/hocuspocus/tree/main/playground), that you can start by 6 | running `npm run playground` in the repository root. They are meant for internal usage during Hocuspocus 7 | development, but they might be useful to understand how everything can be used. 8 | 9 | ## Backend 10 | 11 | ### Installation 12 | 13 | You can install other packages later, let’s start with a basic version for now: 14 | 15 | ```bash 16 | npm install @hocuspocus/server 17 | 18 | # Please note, yarn does not install peer-deps by default, so when using yarn, 19 | # you'll need to install y-protocols and yjs as well. 20 | yarn add @hocuspocus/server y-protocols yjs 21 | ``` 22 | 23 | ### Usage 24 | 25 | ```js 26 | import { Server } from "@hocuspocus/server"; 27 | 28 | // Configure the server … 29 | const server = new Server({ 30 | port: 1234, 31 | }); 32 | 33 | // … and run it! 34 | server.listen(); 35 | ``` 36 | 37 | ## Frontend 38 | 39 | ### Installation 40 | 41 | ```bash 42 | npm install @hocuspocus/provider yjs 43 | ``` 44 | 45 | ### Usage 46 | 47 | Now, you’ll need to use Y.js in your frontend and connect to the server with the WebSocket provider. With Tiptap, our very own text editor, it’s also just a few lines of code. 48 | 49 | ```js 50 | import * as Y from "yjs"; 51 | import { HocuspocusProvider } from "@hocuspocus/provider"; 52 | 53 | // Connect it to the backend 54 | const provider = new HocuspocusProvider({ 55 | url: "ws://127.0.0.1:1234", 56 | name: "example-document", 57 | }); 58 | 59 | // Define `tasks` as an Array 60 | const tasks = provider.document.getArray("tasks"); 61 | 62 | // Listen for changes 63 | tasks.observe(() => { 64 | console.log("tasks were modified"); 65 | }); 66 | 67 | // Add a new task 68 | tasks.push(["buy milk"]); 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/guides/auth.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Authentication & Authorization 6 | 7 | ## Introduction 8 | 9 | With the `onAuthenticate` hook you can check if a client is authenticated and authorized to view the current document. In a real world application this would probably be a request to an API, a database query or something else. 10 | 11 | ## Example 12 | 13 | When throwing an error or rejecting the returned Promise, the connection to the client will be terminated (see [server hooks lifecycle](/server/hooks#lifecycle)). If the client is authorized and authenticated you can also return contextual data such as a user id which will be accessible in other hooks. But you don’t need to. 14 | 15 | For more information on the hook and its payload, check out its [section](/server/hooks#on-authenticate). 16 | 17 | ```js 18 | import { Server } from "@hocuspocus/server"; 19 | 20 | const server = new Server({ 21 | async onAuthenticate(data) { 22 | const { token } = data; 23 | 24 | // Example test if a user is authenticated with a token passed from the client 25 | if (token !== "super-secret-token") { 26 | throw new Error("Not authorized!"); 27 | } 28 | 29 | // You can set contextual data to use it in other hooks 30 | return { 31 | user: { 32 | id: 1234, 33 | name: "John", 34 | }, 35 | }; 36 | }, 37 | }); 38 | 39 | server.listen(); 40 | ``` 41 | 42 | On the client you would pass the "token" parameter as one of the Hocuspocus options, like so: 43 | 44 | ```js 45 | new HocuspocusProvider({ 46 | url: "ws://127.0.0.1:1234", 47 | name: "example-document", 48 | document: ydoc, 49 | token: "super-secret-token", 50 | }); 51 | ``` 52 | 53 | ## Read only mode 54 | 55 | If you want to restrict the current user only to read the document and its updates but not apply 56 | updates themselves, you can use the `connection` property in the `onAuthenticate` hooks payload: 57 | 58 | ```js 59 | import { Server } from "@hocuspocus/server"; 60 | 61 | const usersWithWriteAccess = ["jane", "john", "christina"]; 62 | 63 | const server = new Server({ 64 | async onAuthenticate(data): Doc { 65 | // Example code to check if the current user has write access by a 66 | // request parameter. In a real world application you would probably 67 | // get the user by a token from your database 68 | if (!usersWithWriteAccess.includes(data.requestParameters.get("user"))) { 69 | // Set the connection to readonly 70 | data.connection.readOnly = true; 71 | } 72 | }, 73 | }); 74 | 75 | server.listen(); 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/guides/awareness.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Awareness 6 | 7 | ## Introduction 8 | 9 | Through awareness, information about all present users can be shared. Awareness allows you to sync the names, cursor positions, or even coordinates in a complex 3D world. Under the hood it has its own CRDT, but does not have a history of updates. You can read more about it in the [Yjs documentation on awareness](https://docs.yjs.dev/getting-started/adding-awareness). 10 | 11 | ## Set your state 12 | Pass a key and a value to set awareness information for the current users, you are free to pass whatever data you would like to share with other users. Here is an example with a name and a hex color under the `user` key: 13 | 14 | You can also read more in [our events section of the provider](/provider/events) about the related events: `awarenessUpdate` and `awarenessChange`. 15 | 16 | ## Set your state 17 | 18 | ```js 19 | // Set the awareness field for the current user 20 | provider.setAwarenessField("user", { 21 | name: "Kevin Jahns", 22 | color: "#ffcc00", 23 | }); 24 | ``` 25 | 26 | ## Listen for changes 27 | 28 | Register an event listener to receive and react to any changes, not only for your, but for all awareness states of all connected users: 29 | 30 | ```js 31 | // Listen for updates to the states of all users 32 | provider.on("awarenessUpdate", ({ states }) => { 33 | console.log(states); 34 | }); 35 | ``` 36 | 37 | ## Usage 38 | 39 | Gosh, all those tiny snippets. Here is complete working example of how that could look like in your app: 40 | 41 | ```js 42 | import * as Y from 'yjs' 43 | import { HocuspocusProvider } from '@hocuspocus/provider' 44 | 45 | // Set up the provider 46 | provider = new HocuspocusProvider({ 47 | url: "ws://127.0.0.1:1234", 48 | name: "awareness-example", 49 | document: new Y.Doc(), 50 | // Listen for updates … 51 | onAwarenessUpdate: ({ states }) => { 52 | console.log(states); 53 | }, 54 | }); 55 | 56 | // For example, listen for mouse movements 57 | document.addEventListener("mousemove", (event) => { 58 | // Share any information you like 59 | provider.setAwarenessField("user", { 60 | name: "Kevin Jahns", 61 | color: "#ffcc00", 62 | mouseX: event.clientX, 63 | mouseY: event.clientY, 64 | }); 65 | }); 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/guides/persistence.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Persistence 6 | 7 | To persist the documents you must instruct the server to: 8 | 9 | 1. Store the document in the `onStoreDocument` hook (which is the same as the `onChange` but with debounce already configured). 10 | 2. Load the document from the database using the `onLoadDocument` hook. 11 | 12 | Actually, you don't even have to use those 2 hooks! We have already created on top of them a simple abstraction in the form of a [database extension](https://tiptap.dev/docs/hocuspocus/server/extensions/database). 13 | 14 | However, in case you are a curious mind, here is an example of what it would be like to do it with hooks (It can be a good way to familiarize yourself with the concepts). 15 | 16 | ```ts 17 | import { debounce } from "debounce"; 18 | import { Server } from "@hocuspocus/server"; 19 | import { Doc } from "yjs"; 20 | 21 | let debounced; 22 | 23 | const server = new Server({ 24 | async onStoreDocument(data) { 25 | // Save to database. Example: 26 | // saveToDatabase(data.document, data.documentName); 27 | }, 28 | 29 | async onLoadDocument(data): Doc { 30 | return loadFromDatabase(data.documentName) || createInitialDocTemplate(); 31 | }, 32 | }); 33 | 34 | server.listen(); 35 | 36 | function createInitialDocTemplate() { 37 | return new Doc(); 38 | // do anything you want here 39 | } 40 | ``` 41 | 42 | ## FAQ: In what format should I save my document? 43 | 44 | In Uint8Array, [which is the format that Yjs encodes its documents](https://docs.yjs.dev/api/document-updates). 45 | You can persist your documents **additionally** in another format like JSON if for some reason you want to. 46 | 47 | Note: Do not be tempted to store the Y.Doc as JSON and recreate it as YJS binary when the user connects. This will cause issues with merging of updates and content will duplicate on new connections. The data must be stored as binary to make use of the YJS format. 48 | -------------------------------------------------------------------------------- /docs/guides/scalability.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Scalability 6 | 7 | ## Introduction 8 | 9 | If you are trying to deploy Hocuspocus in a HA setup or solve issues due to too many connections / network traffic, 10 | you can use our redis extension: [extension-redis](/server/extensions/redis). 11 | 12 | Yjs is really efficient (see https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/), so if you're having issues about 13 | cpu / memory usage, our suggested solution at the moment is to deploy multiple independent Hocuspocus instances and split users by a document 14 | identifier. 15 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [![Version](https://img.shields.io/npm/v/@hocuspocus/server.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/server) 4 | [![Downloads](https://img.shields.io/npm/dm/@hocuspocus/server.svg)](https://npmcharts.com/compare/@hocuspocus/server?minimal=true) 5 | [![License](https://img.shields.io/npm/l/@hocuspocus/server.svg)](https://www.npmjs.com/package/@hocuspocus/server) 6 | [![Chat](https://img.shields.io/badge/chat-on%20discord-7289da.svg?sanitize=true)](https://discord.gg/WtJ49jGshW) 7 | 8 | Hocuspocus is a suite of tools to bring collaboration to your application. It’s based 9 | on [Y.js](https://github.com/yjs/yjs) (by Kevin Jahns), which is amazing to sync and merge changes 10 | from clients in real-time. But you can also use it to build offline-first apps, and sync changes 11 | later. We’ll make sure to resolve conflicts and keep everything in sync, always. 12 | 13 | ## What is Y.js? 14 | 15 | Y.js merges changes from users without conflicts and in real-time. Compared to other 16 | implementations, it is super performant and “kicks the pants off” (Joseph Gentle, Ex-Google Wave 17 | Engineer, [source](https://josephg.com/blog/crdts-are-the-future/)). 18 | 19 | For such a Conflict-free Replication Data Type (CRDT), it doesn’t matter in which order changes are 20 | applied. It’s a little bit like Git, where it doesn’t matter when changes are committed. Also, every 21 | copy of the data is worth the same. 22 | 23 | This enables you to build performant real-time applications, add collaboration to your existing app, 24 | sync awareness states and think offline-first. 25 | 26 | ## The Hocuspocus Server 27 | 28 | With Y.js, you can use whatever network protocol you like to send changes to other clients, but the 29 | most popular one is a WebSocket. The Hocuspocus Server is a WebSocket backend, which has everything 30 | to get started quickly, to integrate Y.js in your existing infrastructure and to scale to a million 31 | users. 32 | 33 | ## Features 34 | 35 | - Merges changes without conflicts 36 | - Doesn’t care when changes come in 37 | - Can sync your whole application state 38 | - Collaborative text editing (with Tiptap, Slate, Quill, Monaco or ProseMirror) 39 | - Integrates into existing applications 40 | - Sends changes to Webhooks 41 | - Scales to millions of users with Redis 42 | - Written in TypeScript 43 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | MIT :-) 3 | 4 | TODO 5 | -------------------------------------------------------------------------------- /docs/links.yaml: -------------------------------------------------------------------------------- 1 | - title: Overview 2 | items: 3 | - title: Introduction 4 | link: /introduction 5 | - title: Getting Started 6 | link: /getting-started 7 | - title: About the project 8 | link: /sponsor 9 | type: sponsor 10 | - title: Contributing 11 | link: /contributing 12 | - title: Upgrade Guide 13 | link: /upgrade 14 | 15 | - title: Server 16 | items: 17 | - title: Cloud 18 | link: /../cloud 19 | type: new 20 | - title: Configuration 21 | link: /server/configuration 22 | - title: Usage 23 | link: /server/usage 24 | - title: Methods 25 | link: /server/methods 26 | - title: Hooks 27 | link: /server/hooks 28 | - title: Extensions 29 | link: /server/extensions 30 | items: 31 | - title: Database 32 | link: /server/extensions/database 33 | - title: SQLite 34 | link: /server/extensions/sqlite 35 | - title: Redis 36 | link: /server/extensions/redis 37 | - title: Logger 38 | link: /server/extensions/logger 39 | - title: Webhook 40 | link: /server/extensions/webhook 41 | - title: Throttle 42 | link: /server/extensions/throttle 43 | - title: Examples 44 | link: /server/examples 45 | 46 | - title: Provider 47 | items: 48 | - title: Introduction 49 | link: /provider/introduction 50 | - title: Configuration 51 | link: /provider/configuration 52 | - title: Events 53 | link: /provider/events 54 | - title: Examples 55 | link: /provider/examples 56 | 57 | - title: Guides 58 | items: 59 | - title: Collaborative editing 60 | link: /guides/collaborative-editing 61 | - title: Auth 62 | link: /guides/auth 63 | - title: Persistence 64 | link: /guides/persistence 65 | - title: Multi/Sub-documents 66 | link: /guides/multi-subdocuments 67 | - title: Scalability 68 | link: /guides/scalability 69 | - title: Awareness 70 | link: /guides/awareness 71 | - title: Custom Extensions 72 | link: /guides/custom-extensions 73 | -------------------------------------------------------------------------------- /docs/provider/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Installation 6 | 7 | ## Introduction 8 | You’ve got your Hocuspocus server running? Time to take the Hocuspocus provider for a test drive! Install it from npm, and start using it with a few lines of code. 9 | 10 | ## Install the provider 11 | 12 | ```bash 13 | npm install yjs @hocuspocus/provider 14 | ``` 15 | 16 | ## Usage 17 | To use it, create a new Y.js document (if you don’t have one already), and attach it to the Hocuspocus provider. 18 | 19 | ```js 20 | import * as Y from 'yjs' 21 | import { HocuspocusProvider } from '@hocuspocus/provider' 22 | 23 | const ydoc = new Y.Doc() 24 | 25 | const provider = new HocuspocusProvider({ 26 | url: 'ws://127.0.0.1:1234', 27 | name: 'example-document', 28 | document: ydoc, 29 | }) 30 | ``` 31 | 32 | Done! You’ve now connected a Y.js document to the Hocuspocus server. Changes to the Y.js document will be synced to all other connected users. 33 | -------------------------------------------------------------------------------- /docs/provider/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [![Version](https://img.shields.io/npm/v/@hocuspocus/provider.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/provider) 4 | [![Downloads](https://img.shields.io/npm/dm/@hocuspocus/provider.svg)](https://npmcharts.com/compare/@hocuspocus/provider?minimal=true) 5 | [![License](https://img.shields.io/npm/l/@hocuspocus/provider.svg)](https://www.npmjs.com/package/@hocuspocus/provider) 6 | [![Chat](https://img.shields.io/badge/chat-on%20discord-7289da.svg?sanitize=true)](https://discord.gg/WtJ49jGshW) 7 | 8 | Providers are the Y.js way to set up communication between different users, or cache the updates in the browser. Hocuspocus comes with its own provider and is not compatible anymore (since v2) with other y-providers, as we are supporting multiplexing to synchronize multiple documents over the same websocket connection. 9 | 10 | It’s coming with WebSocket message authentication, a debug mode to add verbose output to the console, a few more event hooks, a different reconnection strategy, an improved error handling and a friendly API for the Awareness protocol. 11 | 12 | All Y.js providers can be used together. That includes the Hocuspocus provider, and the original [y-websocket](https://github.com/yjs/y-websocket) provider, [y-webrtc](https://github.com/yjs/y-webrtc), [y-indexeddb](https://github.com/yjs/y-indexeddb) (for in-browser caching) or [y-dat](https://github.com/yjs/y-dat) (work in progress). You can use the Hocuspocus provider with y-webrtc and other y-providers, but when using Hocuspocus you'll have to use our HocuspocusProvider, and server implementations apart from hocuspocus probably won't work too. You can however instantiate multiple providers if you want to synchronize with Hocuspocus and other servers. 13 | -------------------------------------------------------------------------------- /docs/server/cloud.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # This has moved 6 | 7 | Please click [here](https://www.tiptap.dev/cloud) 8 | -------------------------------------------------------------------------------- /docs/server/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Configuration 6 | 7 | ## Introduction 8 | 9 | There are only a few settings to pass for now. Most things are controlled through [hooks](/server/hooks). 10 | 11 | ## Settings 12 | 13 | | Setting | Description | Default value | 14 | |---------------|--------------------------------------------------------------------------------------------------------------------------------------|-----------------| 15 | | `name` | A name for the instance, used for logging. | | 16 | | `port` | The port the server should listen on. | `80` | 17 | | `timeout` | A connection healthcheck interval in milliseconds. | `30000 (= 30s)` | 18 | | `debounce` | Debounces the call of the onStoreDocument hook for the given amount of time in ms. Otherwise every single update would be persisted. | `2000 (= 2s)` | 19 | | `maxDebounce` | Makes sure to call onStoreDocument at least in the given amount of time (ms). | `10000 (= 10s)` | 20 | | `quiet` | By default, the servers show a start screen. If passed false, the server will start quietly. | `false` | 21 | 22 | ## Usage 23 | 24 | ```js 25 | import { Server } from "@hocuspocus/server"; 26 | 27 | const server = new Server({ 28 | name: "hocuspocus-fra1-01", 29 | port: 1234, 30 | timeout: 30000, 31 | debounce: 5000, 32 | maxDebounce: 30000, 33 | quiet: true, 34 | }); 35 | 36 | server.listen(); 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/server/extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | You can see [our guide to custom extensions](/guides/custom-extensions) to find out how to create your own. 4 | 5 | ## Table of Contents 6 | 7 | We already created some very useful extensions you should check out for sure: 8 | 9 | | Extension | Description | 10 | |----------------------------------------------|--------------------------------------------------------------------------------| 11 | | [Database](https://tiptap.dev/docs/hocuspocus/server/extensions/database) | A generic database driver that is easily adjustable to work with any database. | 12 | | [Logger](https://tiptap.dev/docs/hocuspocus/server/extensions/logger) | Add logging to Hocuspocus. | 13 | | [Redis](https://tiptap.dev/docs/hocuspocus/server/extensions/redis) | Scale Hocuspocus horizontally with Redis. | 14 | | [SQLite](https://tiptap.dev/docs/hocuspocus/server/extensions/sqlite) | Persist documents to SQLite. | 15 | | [Throttle](https://tiptap.dev/docs/hocuspocus/server/extensions/throttle) | Throttle connections by ips. | 16 | | [Webhook](https://tiptap.dev/docs/hocuspocus/server/extensions/webhook) | Send document changes via webhook to your API. | 17 | -------------------------------------------------------------------------------- /docs/server/extensions/database.md: -------------------------------------------------------------------------------- 1 | # Extension Database 2 | 3 | Store your data in whatever data store you already have with the generic database extension. 4 | It takes a Promise to fetch data and another Promise to store the data, that’s all. Hocuspocus will handle the rest. 5 | 6 | ## Installation 7 | 8 | Install the database extension like this: 9 | 10 | ```bash 11 | npm install @hocuspocus/extension-database 12 | ``` 13 | 14 | ## Configuration 15 | 16 | **fetch** 17 | 18 | Expects an async function (or Promise) which returns a Y.js compatible Uint8Array (or null). 19 | Make sure to return the same Uint8Array that was saved in store(), and do not create a new Ydoc, 20 | as doing so would lead to a new history (and duplicated content). 21 | 22 | If you want to initially create a Ydoc based off raw text/json, you can do so here using a transformer of your choice 23 | (e.g. `TiptapTransformer.toYdoc`, or `ProsemirrorTransformer.toYdoc`) 24 | 25 | **store** 26 | 27 | Expects an async function (or Promise) which persists the Y.js binary data somewhere. 28 | 29 | ## Usage 30 | 31 | The following example uses SQLite to store and retrieve data. You can replace that part with whatever data store you 32 | have. As long as you return a Promise you can store data with PostgreSQL, MySQL, MongoDB, S3 … If you actually want to 33 | use SQLite, you can have a look at the [SQLite extension](/server/extensions#Sqlite). 34 | 35 | ```js 36 | import { Server } from "@hocuspocus/server"; 37 | import { Database } from "@hocuspocus/extension-database"; 38 | import sqlite3 from "sqlite3"; 39 | 40 | const server = new Server({ 41 | extensions: [ 42 | new Database({ 43 | // Return a Promise to retrieve data … 44 | fetch: async ({ documentName }) => { 45 | return new Promise((resolve, reject) => { 46 | this.db?.get( 47 | ` 48 | SELECT data FROM "documents" WHERE name = $name ORDER BY rowid DESC 49 | `, 50 | { 51 | $name: documentName, 52 | }, 53 | (error, row) => { 54 | if (error) { 55 | reject(error); 56 | } 57 | 58 | resolve(row?.data); 59 | } 60 | ); 61 | }); 62 | }, 63 | // … and a Promise to store data: 64 | store: async ({ documentName, state }) => { 65 | this.db?.run( 66 | ` 67 | INSERT INTO "documents" ("name", "data") VALUES ($name, $data) 68 | ON CONFLICT(name) DO UPDATE SET data = $data 69 | `, 70 | { 71 | $name: documentName, 72 | $data: state, 73 | } 74 | ); 75 | }, 76 | }), 77 | ], 78 | }); 79 | 80 | server.listen(); 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/server/extensions/logger.md: -------------------------------------------------------------------------------- 1 | # Extension Logger 2 | 3 | Hocuspocus doesn’t log anything. Thanks to this simple extension it will. 4 | 5 | ## Installation 6 | 7 | Install the Logger package with: 8 | 9 | ```bash 10 | npm install @hocuspocus/extension-logger 11 | ``` 12 | 13 | ## Configuration 14 | 15 | **Instance name** 16 | 17 | You can prepend all logging messages with a configured string. 18 | 19 | ```js 20 | import { Server } from "@hocuspocus/server"; 21 | import { Logger } from "@hocuspocus/extension-logger"; 22 | 23 | const server = new Server({ 24 | name: "hocuspocus-fra1-01", 25 | extensions: [new Logger()], 26 | }); 27 | 28 | server.listen(); 29 | ``` 30 | 31 | **Disable messages** 32 | 33 | You can disable logging for specific messages. 34 | 35 | ```js 36 | import { Server } from "@hocuspocus/server"; 37 | import { Logger } from "@hocuspocus/extension-logger"; 38 | 39 | const server = new Server({ 40 | extensions: [ 41 | new Logger({ 42 | onLoadDocument: false, 43 | onChange: false, 44 | onConnect: false, 45 | onDisconnect: false, 46 | onUpgrade: false, 47 | onRequest: false, 48 | onListen: false, 49 | onDestroy: false, 50 | onConfigure: false, 51 | }), 52 | ], 53 | }); 54 | 55 | server.listen(); 56 | ``` 57 | 58 | **Custom logger** 59 | 60 | You can even pass a custom function to log messages. 61 | 62 | ```js 63 | import { Server } from "@hocuspocus/server"; 64 | import { Logger } from "@hocuspocus/extension-logger"; 65 | 66 | const server = new Server({ 67 | extensions: [ 68 | new Logger({ 69 | log: (message) => { 70 | // do something custom here 71 | console.log(message); 72 | }, 73 | }), 74 | ], 75 | }); 76 | 77 | server.listen(); 78 | ``` 79 | -------------------------------------------------------------------------------- /docs/server/extensions/redis.md: -------------------------------------------------------------------------------- 1 | # Extension Redis 2 | 3 | Hocuspocus can be scaled horizontally using the Redis extension. You can spawn multiple instances of the server behind a 4 | load balancer and sync changes and awareness states through Redis. Hocuspocus will propagate all received updates to all other instances 5 | using Redis and thus forward updates to all clients of all Hocuspocus instances. 6 | 7 | The Redis extension does not persist data; it only syncs data between instances. Use the [Database](/server/extensions/database) extension to store your documents. 8 | 9 | Please note that all messages will be handled on all instances of Hocuspocus, so if you are trying to reduce cpu load by spawning multiple 10 | servers, you should not connect them via Redis. 11 | 12 | Thanks to [@tommoor](https://github.com/tommoor) for writing the initial implementation of that extension. 13 | 14 | ## Installation 15 | 16 | Install the Redis extension with: 17 | 18 | ```bash 19 | npm install @hocuspocus/extension-redis 20 | ``` 21 | 22 | ## Configuration 23 | 24 | For a full documentation on all available Redis and Redis cluster options, check out the 25 | [ioredis API docs](https://github.com/luin/ioredis/blob/master/API.md). 26 | 27 | ```js 28 | import { Server } from "@hocuspocus/server"; 29 | import { Redis } from "@hocuspocus/extension-redis"; 30 | 31 | const server = new Server({ 32 | extensions: [ 33 | new Redis({ 34 | // [required] Hostname of your Redis instance 35 | host: "127.0.0.1", 36 | 37 | // [required] Port of your Redis instance 38 | port: 6379, 39 | }), 40 | ], 41 | }); 42 | 43 | server.listen(); 44 | ``` 45 | 46 | ## Usage 47 | 48 | The Redis extension works well with the database extension. Once an instance stores a document, it’s blocked for all 49 | other instances to avoid write conflicts. 50 | 51 | ```js 52 | import { Server } from "@hocuspocus/server"; 53 | import { Logger } from "@hocuspocus/extension-logger"; 54 | import { Redis } from "@hocuspocus/extension-redis"; 55 | import { SQLite } from "@hocuspocus/extension-sqlite"; 56 | 57 | // Server 1 58 | const server = new Server({ 59 | name: "server-1", // make sure to use unique server names 60 | port: 1234, 61 | extensions: [ 62 | new Logger(), 63 | new Redis({ 64 | host: "127.0.0.1", // make sure to use the same Redis instance :-) 65 | port: 6379, 66 | }), 67 | new SQLite(), 68 | ], 69 | }); 70 | 71 | server.listen(); 72 | 73 | // Server 2 74 | const anotherServer = new Server({ 75 | name: "server-2", 76 | port: 1235, 77 | extensions: [ 78 | new Logger(), 79 | new Redis({ 80 | host: "127.0.0.1", 81 | port: 6379, 82 | }), 83 | new SQLite(), 84 | ], 85 | }); 86 | 87 | anotherServer.listen(); 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/server/extensions/sqlite.md: -------------------------------------------------------------------------------- 1 | # Extension SQLite 2 | 3 | ## Introduction 4 | 5 | For local development purposes it’s nice to have a database ready to go with a few lines of code. That’s what the SQLite 6 | extension is for. 7 | 8 | ## Installation 9 | 10 | Install the SQLite extension like this: 11 | 12 | ```bash 13 | npm install @hocuspocus/extension-sqlite 14 | ``` 15 | 16 | ## Configuration 17 | 18 | **database** 19 | 20 | Valid values are filenames, ":memory:" for an anonymous in-memory database and an empty 21 | string for an anonymous disk-based database. Anonymous databases are not persisted and 22 | when closing the database handle, their contents are lost. 23 | 24 | https://github.com/mapbox/node-sqlite3/wiki/API#new-sqlite3databasefilename-mode-callback 25 | 26 | Default: `:memory:` 27 | 28 | **schema** 29 | 30 | The SQLite schema that’s created for you. 31 | 32 | Default: 33 | 34 | ```sql 35 | CREATE TABLE IF NOT EXISTS "documents" ( 36 | "name" varchar(255) NOT NULL, 37 | "data" blob NOT NULL, 38 | UNIQUE(name) 39 | ) 40 | ``` 41 | 42 | **fetch** 43 | 44 | An async function to retrieve data from SQLite. If you change the schema, you probably want to override the query. 45 | 46 | **store** 47 | 48 | An async function to store data in SQLite. If you change the schema, you probably want to override the query. 49 | 50 | **Usage** 51 | 52 | By default, data is just “stored” in `:memory:`, so it’s wiped when you stop the server. You can pass a file name to 53 | persist data on the disk. 54 | 55 | ```js 56 | import { Server } from "@hocuspocus/server"; 57 | import { SQLite } from "@hocuspocus/extension-sqlite"; 58 | 59 | const server = new Server({ 60 | extensions: [ 61 | new SQLite({ 62 | database: "db.sqlite", 63 | }), 64 | ], 65 | }); 66 | 67 | server.listen(); 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/server/extensions/throttle.md: -------------------------------------------------------------------------------- 1 | # Extension Throttle 2 | 3 | This extension throttles connection attempts and bans ip-addresses if it crosses the configured threshold. 4 | 5 | Make sure to register it **before** any other extensions! 6 | 7 | ## Installation 8 | 9 | Install the Throttle package with: 10 | 11 | ```bash 12 | npm install @hocuspocus/extension-throttle 13 | ``` 14 | 15 | ## Configuration 16 | 17 | ```js 18 | import { Server } from "@hocuspocus/server"; 19 | import { Throttle } from "@hocuspocus/extension-throttle"; 20 | 21 | const server = new Server({ 22 | extensions: [ 23 | new Throttle({ 24 | // [optional] allows up to 15 connection attempts per ip address per minute. 25 | // set to null or false to disable throttling, defaults to 15 26 | throttle: 15, 27 | 28 | // [optional] bans ip addresses for 5 minutes after reaching the threshold 29 | // defaults to 5 30 | banTime: 5, 31 | }), 32 | ], 33 | }); 34 | 35 | server.listen(); 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/server/methods.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Methods 6 | 7 | ## Server 8 | 9 | | Method | Description | 10 | |--------------------------|---------------------------------------------------| 11 | | `listen(port, callback)` | Start the server. | 12 | | `destroy()` | Stop the server. | 13 | 14 | ```js 15 | import { Server } from "@hocuspocus/server"; 16 | 17 | // Configure … 18 | const server = new Server({ 19 | port: 1234, 20 | }); 21 | 22 | // Listen … 23 | server.listen(); 24 | 25 | // Destroy … 26 | server.destroy(); 27 | ``` 28 | 29 | ## Hocuspocus 30 | 31 | | Method | Description | 32 | |------------------------------------------------|---------------------------------------------------| 33 | | `configure(configuration)` | Pass custom settings. | 34 | | `handleConnection(incoming, request, context)` | Bind the server to an existing server instance. | 35 | | `getDocumentsCount()` | Get the total number of active documents | 36 | | `getConnectionsCount()` | Get the total number of active connections | 37 | | `closeConnections(documentName?)` | Close all connections, or to a specific document. | 38 | | `openDirectConnection(documentName, context)` | Creates a local connection to a document. | 39 | -------------------------------------------------------------------------------- /docs/server/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # Usage 6 | 7 | There are two ways on how you can use hocuspocus. Either with the built-in server. Or with another framework, for 8 | example with [Express](/server/examples#express). 9 | 10 | ## Hocuspocus Server 11 | 12 | Using the built-in server make sure to import `Server` from `@hocuspocus/server`. You configure the server as described 13 | under [configuration](/server/configuration). The built-in server spins up a webserver and a websocket server. 14 | 15 | ```js 16 | import { Server } from "@hocuspocus/server"; 17 | 18 | // Configure the server 19 | const server = new Server({ 20 | port: 1234, 21 | }); 22 | 23 | // Listen … 24 | server.listen(); 25 | 26 | // Destroy … 27 | server.destroy(); 28 | ``` 29 | 30 | You can access the instance of hocuspocus through the webserver to call it's [methods](/server/methods). 31 | 32 | ```js 33 | // … 34 | 35 | server.hocuspocus.getDocumentsCount(); 36 | ``` 37 | 38 | ## Hocuspocus 39 | 40 | As mentioned earlier, you can use hocuspocus without the built-in server. Make sure to import `Hocuspocus` from the 41 | `@hocuspocus/server` package. 42 | 43 | ```js 44 | import { Hocuspocus } from "@hocuspocus/server"; 45 | 46 | // Configure hocuspocus 47 | const hocuspocus = new Hocuspocus({ 48 | name: "hocuspocus-fra1-01", 49 | }) 50 | 51 | // … 52 | ``` 53 | 54 | Check out the [examples](/server/examples) to learn more. 55 | -------------------------------------------------------------------------------- /docs/sponsor.md: -------------------------------------------------------------------------------- 1 | --- 2 | tableOfContents: true 3 | --- 4 | 5 | # About the project 6 | 7 | ## Introduction 8 | 9 | To deliver a top-notch developer experience and user experience, we put hundreds of hours of unpaid work into Hocuspocus. Your funding helps us to make this work more and more financially sustainable. This enables us to provide helpful support, maintain all our packages, keep everything up to date, and develop new features and extensions for Hocuspocus. 10 | 11 | Give back to the open source community and [sponsor us on GitHub](https://github.com/sponsors/ueberdosis)! ♥ 12 | 13 | ## Your benefits as a sponsor 💖 14 | 15 | - Your issues and pull requests get a `sponsor ♥` label 16 | - Get a sponsor badge in all your comments on GitHub 17 | - Invest in the future of Hocuspocus 18 | - Give back to the open source community 19 | - Show support in your GitHub profile 20 | 21 | Sounds good? [Sponsor us on GitHub!](https://github.com/sponsors/ueberdosis) 22 | 23 | ## The maintainers of Hocuspocus 24 | 25 | If you are thankful for Hocuspocus, you should say thank you to the lovely people at [überdosis](https://ueberdosis.io), the company that builds this software. 26 | 27 | AND you should definitely hire us if you want us to design and build an amazing digital product for you. Bonus points if it’s somehow text editing related. 28 | 29 | ## Frequently asked questions 30 | 31 | ### I can’t use GitHub. How can I support you? 32 | 33 | If you’re a company, don’t want to use GitHub, don’t have a credit card or want a proper invoice from us, just reach out to us at [humans@tiptap.dev](mailto:humans@tiptap.dev). 34 | 35 | We have an [OpenCollective](https://opencollective.com/tiptap), which allows you to send money through transfer, PayPal or credit card. Donations are tax deductible for US companies. 36 | 37 | ### I want consulting. What’s your rate? 38 | 39 | If you have an issue, a question, want to talk something through or anything else, [please use GitHub issues](https://github.com/ueberdosis/hocuspocus/issues) to keep everything accessible to the whole community. For everything else, reach out to [humans@tiptap.dev](mailto:humans@tiptap.dev). We can take on a limited number of custom development and consulting contracts. 40 | -------------------------------------------------------------------------------- /docs/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Upgrading to 3.0 from 2.x 4 | 5 | With the upgrade to the new version, the initialization of hocuspocus has changed. As described on the [usage](/server/usage) 6 | side, there are two ways on how you can use hocuspocus. With the built-in server. Or like a library with other 7 | frameworks (like [express](/server/examples#express)). To make things simpler and enable more features in the future, 8 | we separated classes and put the server into its own class. 9 | 10 | ### Usage with .configure() 11 | 12 | It is no longer possible to use hocuspocus with `.configure()`. You always have to create a new instance by yourself. 13 | 14 | **Old Way** 15 | ```js 16 | import { Server } from "@hocuspocus/server"; 17 | 18 | const server = Server.configure({ 19 | port: 1234, 20 | }); 21 | 22 | server.listen(); 23 | ``` 24 | 25 | **New Way** 26 | ```js 27 | import { Server } from "@hocuspocus/server"; 28 | 29 | const server = new Server({ 30 | port: 1234, 31 | }); 32 | 33 | server.listen(); 34 | ``` 35 | 36 | Notice, that the import has not changed. The configuration options stay the same here. 37 | 38 | ### Usage of Hocuspocus without built-in server 39 | 40 | If you have used Hocuspocus without the built-in server before, you have to update your setup as well. 41 | 42 | **Old Way** 43 | ```js 44 | import { Server } from "@hocuspocus/server"; 45 | 46 | const server = Server.configure({ 47 | // ... 48 | }); 49 | ``` 50 | 51 | **New Way** 52 | ```js 53 | import { Hocuspocus } from "@hocuspocus/server"; 54 | 55 | const hocuspocus = new Hocuspocus({ 56 | // ... 57 | }); 58 | 59 | // You still use handleConnection as you did before. 60 | hocuspocus.handleConnection(...); 61 | ``` 62 | 63 | Notice the change of the import from `Server` to `Hocuspocus` as well as the initialization with `new Hocuspocus()`. 64 | See [examples](/server/examples) for more on that. 65 | 66 | ### Change of the servers listen signature 67 | 68 | The `.listen()` function of the server was quite versatile. We simplified the signature of it while you can still reach 69 | the same behavior as before. 70 | 71 | **Old Signature** 72 | ```js 73 | async listen( 74 | portOrCallback: number | ((data: onListenPayload) => Promise) | null = null, 75 | callback: any = null, 76 | ): Promise 77 | ``` 78 | 79 | **New Signature** 80 | ```js 81 | async listen(port?: number, callback: any = null): Promise 82 | ``` 83 | 84 | The listen method still returns a Promise which will be resolved to Hocuspocus, if nothing fails. 85 | 86 | Both the callbacks you could provide in the old version were added to the `onListen` hook. This is still the case with 87 | the callback on the new version. But you can't provide just a callback on the first parameter anymore. If you just want 88 | to add a callback you also still can add it within the configuration of the server. 89 | 90 | ```js 91 | import { Server } from "@hocuspocus/server"; 92 | 93 | const server = new Server({ 94 | async onListen(data) { 95 | console.log(`Server is listening on port "${data.port}"!`); 96 | }, 97 | }); 98 | 99 | server.listen() 100 | ``` 101 | -------------------------------------------------------------------------------- /docsearch.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "index_name": "hocuspocus", 3 | "start_urls": [ 4 | "https://tiptap.dev/docs/hocuspocus" 5 | ], 6 | "sitemap_alternate_links": true, 7 | "selectors": { 8 | "default": { 9 | "lvl0": { 10 | "selector": "", 11 | "global": true, 12 | "default_value": "Documentation" 13 | }, 14 | "lvl1": "main h1", 15 | "lvl2": "main h2", 16 | "lvl3": "main h3", 17 | "lvl4": "main h4", 18 | "lvl5": "main h5", 19 | "lvl6": "main h6", 20 | "text": "main p, main li, main pre, main td" 21 | } 22 | }, 23 | "strip_chars": " .,;:#", 24 | "custom_settings": { 25 | "separatorsToIndex": "_", 26 | "attributesForFaceting": [ 27 | "type", 28 | "lang" 29 | ], 30 | "attributesToRetrieve": [ 31 | "hierarchy", 32 | "text", 33 | "anchor", 34 | "url" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": { 3 | "publish": { 4 | "conventionalCommits": true 5 | } 6 | }, 7 | "version": "3.1.1" 8 | } -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/cli", 3 | "description": "a CLI tool to start a local Hocuspocus server", 4 | "version": "3.1.1", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "yjs", 9 | "yjs-websocket" 10 | ], 11 | "license": "MIT", 12 | "type": "module", 13 | "bin": { 14 | "hocuspocus": "./src/index.js", 15 | "@hocuspocus/cli": "./src/index.js" 16 | }, 17 | "files": [ 18 | "src" 19 | ], 20 | "dependencies": { 21 | "@hocuspocus/extension-logger": "^3.1.1", 22 | "@hocuspocus/extension-sqlite": "^3.1.1", 23 | "@hocuspocus/extension-webhook": "^3.1.1", 24 | "@hocuspocus/server": "^3.1.1", 25 | "meow": "^13.0.0" 26 | }, 27 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 28 | } 29 | -------------------------------------------------------------------------------- /packages/cli/src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import meow from 'meow' 4 | import { Server } from '@hocuspocus/server' 5 | import { Logger } from '@hocuspocus/extension-logger' 6 | import { Webhook } from '@hocuspocus/extension-webhook' 7 | import { SQLite } from '@hocuspocus/extension-sqlite' 8 | 9 | export const cli = meow(` 10 | Usage 11 | $ hocuspocus [options] 12 | 13 | Options 14 | --port=, -p Set the port, defaults to 1234. 15 | --webhook=, -w Configure a custom webhook. 16 | --sqlite=, -s Store data in a SQLite database, defaults to :memory:. 17 | --version Show the current version number. 18 | 19 | Examples 20 | $ hocuspocus --port 8080 21 | $ hocuspocus --webhook http://localhost/webhooks/hocuspocus 22 | $ hocuspocus --sqlite 23 | $ hocuspocus --sqlite database/default.sqlite 24 | `, { 25 | importMeta: import.meta, 26 | flags: { 27 | port: { 28 | type: 'string', 29 | shortFlag: 'p', 30 | default: '1234', 31 | }, 32 | webhook: { 33 | type: 'string', 34 | shortFlag: 'w', 35 | default: '', 36 | }, 37 | sqlite: { 38 | type: 'string', 39 | shortFlag: 's', 40 | default: '', 41 | }, 42 | }, 43 | }) 44 | 45 | export const getConfiguredWebhookExtension = () => { 46 | return cli.flags.webhook ? new Webhook({ 47 | url: cli.flags.webhook, 48 | }) : undefined 49 | } 50 | 51 | export const getConfiguredSQLiteExtension = () => { 52 | if (cli.flags.sqlite) { 53 | return new SQLite({ 54 | database: cli.flags.sqlite, 55 | }) 56 | } if (process.argv.includes('--sqlite')) { 57 | return new SQLite() 58 | } 59 | 60 | return undefined 61 | } 62 | 63 | const server = new Server({ 64 | port: parseInt(cli.flags.port, 10), 65 | extensions: [ 66 | new Logger(), 67 | getConfiguredWebhookExtension(), 68 | getConfiguredSQLiteExtension(), 69 | ].filter(extension => extension), 70 | }) 71 | 72 | server.listen() 73 | -------------------------------------------------------------------------------- /packages/common/README.md: -------------------------------------------------------------------------------- 1 | # @hocuspocus/common 2 | [![Version](https://img.shields.io/npm/v/@hocuspocus/common.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/common) 3 | [![Downloads](https://img.shields.io/npm/dm/@hocuspocus/common.svg)](https://npmcharts.com/compare/tiptap?minimal=true) 4 | [![License](https://img.shields.io/npm/l/@hocuspocus/common.svg)](https://www.npmjs.com/package/@hocuspocus/common) 5 | [![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) 6 | 7 | ## Introduction 8 | Hocuspocus is an opinionated collaborative editing backend for [Tiptap](https://github.com/ueberdosis/tiptap) – based on [Y.js](https://github.com/yjs/yjs), a CRDT framework with a powerful abstraction of shared data. 9 | 10 | ## Official Documentation 11 | Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus). 12 | 13 | ## License 14 | Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md). 15 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/common", 3 | "description": "shared code for multiple Hocuspocus packages", 4 | "version": "3.1.1", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus" 8 | ], 9 | "license": "MIT", 10 | "type": "module", 11 | "main": "dist/hocuspocus-common.cjs", 12 | "module": "dist/hocuspocus-common.esm.js", 13 | "types": "dist/packages/common/src/index.d.ts", 14 | "exports": { 15 | "source": { 16 | "import": "./src/index.ts" 17 | }, 18 | "default": { 19 | "import": "./dist/hocuspocus-common.esm.js", 20 | "require": "./dist/hocuspocus-common.cjs", 21 | "types": "./dist/packages/common/src/index.d.ts" 22 | } 23 | }, 24 | "files": [ 25 | "src", 26 | "dist" 27 | ], 28 | "gitHead": "cd788b6a315f608ef531524409abdce1e6790726", 29 | "dependencies": { 30 | "lib0": "^0.2.87" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/common/src/CloseEvents.ts: -------------------------------------------------------------------------------- 1 | export interface CloseEvent { 2 | code: number, 3 | reason: string, 4 | } 5 | 6 | /** 7 | * The server is terminating the connection because a data frame was received 8 | * that is too large. 9 | * See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code 10 | */ 11 | export const MessageTooBig: CloseEvent = { 12 | code: 1009, 13 | reason: 'Message Too Big', 14 | } 15 | 16 | /** 17 | * The server successfully processed the request, asks that the requester reset 18 | * its document view, and is not returning any content. 19 | */ 20 | export const ResetConnection: CloseEvent = { 21 | code: 4205, 22 | reason: 'Reset Connection', 23 | } 24 | 25 | /** 26 | * Similar to Forbidden, but specifically for use when authentication is required and has 27 | * failed or has not yet been provided. 28 | */ 29 | export const Unauthorized: CloseEvent = { 30 | code: 4401, 31 | reason: 'Unauthorized', 32 | } 33 | 34 | /** 35 | * The request contained valid data and was understood by the server, but the server 36 | * is refusing action. 37 | */ 38 | export const Forbidden: CloseEvent = { 39 | code: 4403, 40 | reason: 'Forbidden', 41 | } 42 | 43 | /** 44 | * The server timed out waiting for the request. 45 | */ 46 | export const ConnectionTimeout: CloseEvent = { 47 | code: 4408, 48 | reason: 'Connection Timeout', 49 | } 50 | -------------------------------------------------------------------------------- /packages/common/src/auth.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from 'lib0/encoding' 2 | import * as decoding from 'lib0/decoding' 3 | 4 | enum AuthMessageType { 5 | Token = 0, 6 | PermissionDenied = 1, 7 | Authenticated = 2, 8 | } 9 | 10 | export const writeAuthentication = (encoder: encoding.Encoder, auth: string) => { 11 | encoding.writeVarUint(encoder, AuthMessageType.Token) 12 | encoding.writeVarString(encoder, auth) 13 | } 14 | 15 | export const writePermissionDenied = (encoder: encoding.Encoder, reason: string) => { 16 | encoding.writeVarUint(encoder, AuthMessageType.PermissionDenied) 17 | encoding.writeVarString(encoder, reason) 18 | } 19 | 20 | export const writeAuthenticated = (encoder: encoding.Encoder, scope: 'readonly' | 'read-write') => { 21 | encoding.writeVarUint(encoder, AuthMessageType.Authenticated) 22 | encoding.writeVarString(encoder, scope) 23 | } 24 | 25 | export const readAuthMessage = ( 26 | decoder: decoding.Decoder, 27 | permissionDeniedHandler: (reason: string) => void, 28 | authenticatedHandler: (scope: string) => void, 29 | ) => { 30 | switch (decoding.readVarUint(decoder)) { 31 | case AuthMessageType.PermissionDenied: { 32 | permissionDeniedHandler(decoding.readVarString(decoder)) 33 | break 34 | } 35 | case AuthMessageType.Authenticated: { 36 | authenticatedHandler(decoding.readVarString(decoder)) 37 | break 38 | } 39 | default: 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/common/src/awarenessStatesToArray.ts: -------------------------------------------------------------------------------- 1 | export const awarenessStatesToArray = (states: Map>) => { 2 | return Array.from(states.entries()).map(([key, value]) => { 3 | return { 4 | clientId: key, 5 | ...value, 6 | } 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.ts' 2 | export * from './CloseEvents.ts' 3 | export * from './awarenessStatesToArray.ts' 4 | export * from './types.ts' 5 | -------------------------------------------------------------------------------- /packages/common/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * State of the WebSocket connection. 3 | * https://developer.mozilla.org/de/docs/Web/API/WebSocket/readyState 4 | */ 5 | export enum WsReadyStates { 6 | Connecting = 0, 7 | Open = 1, 8 | Closing = 2, 9 | Closed = 3, 10 | } 11 | -------------------------------------------------------------------------------- /packages/extension-database/README.md: -------------------------------------------------------------------------------- 1 | # @hocuspocus/extension-database 2 | [![Version](https://img.shields.io/npm/v/@hocuspocus/extension-database.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-database) 3 | [![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-database.svg)](https://npmcharts.com/compare/tiptap?minimal=true) 4 | [![License](https://img.shields.io/npm/l/@hocuspocus/extension-database.svg)](https://www.npmjs.com/package/@hocuspocus/extension-database) 5 | [![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) 6 | 7 | ## Introduction 8 | Hocuspocus is an opinionated collaborative editing backend for [Tiptap](https://github.com/ueberdosis/tiptap) – based on [Y.js](https://github.com/yjs/yjs), a CRDT framework with a powerful abstraction of shared data. 9 | 10 | ## Official Documentation 11 | Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus). 12 | 13 | ## License 14 | Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md). 15 | -------------------------------------------------------------------------------- /packages/extension-database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/extension-database", 3 | "description": "a generic Hocuspocus persistence driver for the database", 4 | "version": "3.1.1", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "yjs" 9 | ], 10 | "license": "MIT", 11 | "type": "module", 12 | "main": "dist/hocuspocus-database.cjs", 13 | "module": "dist/hocuspocus-database.esm.js", 14 | "types": "dist/packages/extension-database/src/index.d.ts", 15 | "exports": { 16 | "source": { 17 | "import": "./src/index.ts" 18 | }, 19 | "default": { 20 | "import": "./dist/hocuspocus-database.esm.js", 21 | "require": "./dist/hocuspocus-database.cjs", 22 | "types": "./dist/packages/extension-database/src/index.d.ts" 23 | } 24 | }, 25 | "files": [ 26 | "src", 27 | "dist" 28 | ], 29 | "dependencies": { 30 | "@hocuspocus/server": "^3.1.1" 31 | }, 32 | "peerDependencies": { 33 | "yjs": "^13.6.8" 34 | }, 35 | "publishConfig": { 36 | "access": "public" 37 | }, 38 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 39 | } 40 | -------------------------------------------------------------------------------- /packages/extension-database/src/Database.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Extension, 3 | onChangePayload, 4 | onLoadDocumentPayload, 5 | storePayload, 6 | fetchPayload, 7 | } from '@hocuspocus/server' 8 | import * as Y from 'yjs' 9 | 10 | export interface DatabaseConfiguration { 11 | /** 12 | * Pass a Promise to retrieve updates from your database. The Promise should resolve to 13 | * an array of items with Y.js-compatible binary data. 14 | */ 15 | fetch: (data: fetchPayload) => Promise, 16 | /** 17 | * Pass a function to store updates in your database. 18 | */ 19 | store: (data: storePayload) => Promise, 20 | } 21 | 22 | export class Database implements Extension { 23 | /** 24 | * Default configuration 25 | */ 26 | configuration: DatabaseConfiguration = { 27 | fetch: async () => null, 28 | store: async () => {}, 29 | } 30 | 31 | /** 32 | * Constructor 33 | */ 34 | constructor(configuration: Partial) { 35 | this.configuration = { 36 | ...this.configuration, 37 | ...configuration, 38 | } 39 | } 40 | 41 | /** 42 | * Get stored data from the database. 43 | */ 44 | async onLoadDocument(data: onLoadDocumentPayload): Promise { 45 | const update = await this.configuration.fetch(data) 46 | 47 | if (update) { 48 | Y.applyUpdate(data.document, update) 49 | } 50 | } 51 | 52 | /** 53 | * Store new updates in the database. 54 | */ 55 | async onStoreDocument(data: onChangePayload) { 56 | await this.configuration.store({ 57 | ...data, 58 | state: Buffer.from( 59 | Y.encodeStateAsUpdate(data.document), 60 | ), 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/extension-database/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Database.ts' 2 | -------------------------------------------------------------------------------- /packages/extension-logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/extension-logger", 3 | "version": "3.1.1", 4 | "description": "hocuspocus logging extension", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "logging", 9 | "logger", 10 | "yjs" 11 | ], 12 | "license": "MIT", 13 | "type": "module", 14 | "main": "dist/hocuspocus-logger.cjs", 15 | "module": "dist/hocuspocus-logger.esm.js", 16 | "types": "dist/packages/extension-logger/src/index.d.ts", 17 | "exports": { 18 | "source": { 19 | "import": "./src/index.ts" 20 | }, 21 | "default": { 22 | "import": "./dist/hocuspocus-logger.esm.js", 23 | "require": "./dist/hocuspocus-logger.cjs", 24 | "types": "./dist/packages/extension-logger/src/index.d.ts" 25 | } 26 | }, 27 | "files": [ 28 | "src", 29 | "dist" 30 | ], 31 | "dependencies": { 32 | "@hocuspocus/server": "^3.1.1" 33 | }, 34 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 35 | } 36 | -------------------------------------------------------------------------------- /packages/extension-logger/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Logger.ts' 2 | -------------------------------------------------------------------------------- /packages/extension-redis/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/extension-redis", 3 | "version": "3.1.1", 4 | "description": "Scale Hocuspocus horizontally with Redis", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "redis", 9 | "yjs" 10 | ], 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "dist/hocuspocus-redis.cjs", 14 | "module": "dist/hocuspocus-redis.esm.js", 15 | "types": "dist/packages/extension-redis/src/index.d.ts", 16 | "exports": { 17 | "source": { 18 | "import": "./src/index.ts" 19 | }, 20 | "default": { 21 | "import": "./dist/hocuspocus-redis.esm.js", 22 | "require": "./dist/hocuspocus-redis.cjs", 23 | "types": "./dist/packages/extension-redis/src/index.d.ts" 24 | } 25 | }, 26 | "files": [ 27 | "src", 28 | "dist" 29 | ], 30 | "devDependencies": { 31 | "@types/lodash.debounce": "^4.0.6", 32 | "@types/redlock": "^4.0.3" 33 | }, 34 | "dependencies": { 35 | "@hocuspocus/server": "^3.1.1", 36 | "ioredis": "^5.6.1", 37 | "kleur": "^4.1.4", 38 | "lodash.debounce": "^4.0.8", 39 | "redlock": "^4.2.0", 40 | "uuid": "^11.0.3" 41 | }, 42 | "peerDependencies": { 43 | "y-protocols": "^1.0.6", 44 | "yjs": "^13.6.8" 45 | }, 46 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 47 | } 48 | -------------------------------------------------------------------------------- /packages/extension-redis/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Redis.ts' 2 | -------------------------------------------------------------------------------- /packages/extension-sqlite/README.md: -------------------------------------------------------------------------------- 1 | # @hocuspocus/extension-sqlite 2 | [![Version](https://img.shields.io/npm/v/@hocuspocus/extension-sqlite.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-sqlite) 3 | [![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-sqlite.svg)](https://npmcharts.com/compare/tiptap?minimal=true) 4 | [![License](https://img.shields.io/npm/l/@hocuspocus/extension-sqlite.svg)](https://www.npmjs.com/package/@hocuspocus/extension-sqlite) 5 | [![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) 6 | 7 | ## Introduction 8 | Hocuspocus is an opinionated collaborative editing backend for [Tiptap](https://github.com/ueberdosis/tiptap) – based on [Y.js](https://github.com/yjs/yjs), a CRDT framework with a powerful abstraction of shared data. 9 | 10 | ## Official Documentation 11 | Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus). 12 | 13 | ## License 14 | Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md). 15 | -------------------------------------------------------------------------------- /packages/extension-sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/extension-sqlite", 3 | "description": "a generic Hocuspocus persistence driver for the sqlite", 4 | "version": "3.1.1", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "yjs" 9 | ], 10 | "license": "MIT", 11 | "type": "module", 12 | "main": "dist/hocuspocus-sqlite.cjs", 13 | "module": "dist/hocuspocus-sqlite.esm.js", 14 | "types": "dist/packages/extension-sqlite/src/index.d.ts", 15 | "exports": { 16 | "source": { 17 | "import": "./src/index.ts" 18 | }, 19 | "default": { 20 | "import": "./dist/hocuspocus-sqlite.esm.js", 21 | "require": "./dist/hocuspocus-sqlite.cjs", 22 | "types": "./dist/packages/extension-sqlite/src/index.d.ts" 23 | } 24 | }, 25 | "files": [ 26 | "src", 27 | "dist" 28 | ], 29 | "dependencies": { 30 | "@hocuspocus/extension-database": "^3.1.1", 31 | "kleur": "^4.1.4", 32 | "sqlite3": "^5.1.7" 33 | }, 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 38 | } 39 | -------------------------------------------------------------------------------- /packages/extension-sqlite/src/SQLite.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseConfiguration } from '@hocuspocus/extension-database' 2 | import { Database } from '@hocuspocus/extension-database' 3 | import sqlite3 from 'sqlite3' 4 | import kleur from 'kleur' 5 | 6 | export const schema = `CREATE TABLE IF NOT EXISTS "documents" ( 7 | "name" varchar(255) NOT NULL, 8 | "data" blob NOT NULL, 9 | UNIQUE(name) 10 | )` 11 | 12 | export const selectQuery = ` 13 | SELECT data FROM "documents" WHERE name = $name ORDER BY rowid DESC 14 | ` 15 | 16 | export const upsertQuery = ` 17 | INSERT INTO "documents" ("name", "data") VALUES ($name, $data) 18 | ON CONFLICT(name) DO UPDATE SET data = $data 19 | ` 20 | 21 | const SQLITE_INMEMORY = ':memory:' 22 | 23 | export interface SQLiteConfiguration extends DatabaseConfiguration { 24 | /** 25 | * Valid values are filenames, ":memory:" for an anonymous in-memory database and an empty 26 | * string for an anonymous disk-based database. Anonymous databases are not persisted and 27 | * when closing the database handle, their contents are lost. 28 | * 29 | * https://github.com/mapbox/node-sqlite3/wiki/API#new-sqlite3databasefilename-mode-callback 30 | */ 31 | database: string, 32 | /** 33 | * The database schema to create. 34 | */ 35 | schema: string, 36 | } 37 | 38 | export class SQLite extends Database { 39 | db?: sqlite3.Database 40 | 41 | configuration: SQLiteConfiguration = { 42 | database: SQLITE_INMEMORY, 43 | schema, 44 | fetch: async ({ documentName }) => { 45 | return new Promise((resolve, reject) => { 46 | this.db?.get(selectQuery, { 47 | $name: documentName, 48 | }, (error, row) => { 49 | if (error) { 50 | reject(error) 51 | } 52 | 53 | resolve((row as any)?.data) 54 | }) 55 | }) 56 | }, 57 | store: async ({ documentName, state }) => { 58 | this.db?.run(upsertQuery, { 59 | $name: documentName, 60 | $data: state, 61 | }) 62 | }, 63 | } 64 | 65 | constructor(configuration?: Partial) { 66 | super({}) 67 | 68 | this.configuration = { 69 | ...this.configuration, 70 | ...configuration, 71 | } 72 | } 73 | 74 | async onConfigure() { 75 | this.db = new sqlite3.Database(this.configuration.database) 76 | this.db.run(this.configuration.schema) 77 | } 78 | 79 | async onListen() { 80 | if (this.configuration.database === SQLITE_INMEMORY) { 81 | console.warn(` ${kleur.yellow('The SQLite extension is configured as an in-memory database. All changes will be lost on restart!')}`) 82 | console.log() 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/extension-sqlite/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SQLite.ts' 2 | -------------------------------------------------------------------------------- /packages/extension-throttle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/extension-throttle", 3 | "version": "3.1.1", 4 | "description": "hocuspocus throttle extension", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "throttle", 9 | "yjs" 10 | ], 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "dist/hocuspocus-throttle.cjs", 14 | "module": "dist/hocuspocus-throttle.esm.js", 15 | "types": "dist/packages/extension-throttle/src/index.d.ts", 16 | "exports": { 17 | "source": { 18 | "import": "./src/index.ts" 19 | }, 20 | "default": { 21 | "import": "./dist/hocuspocus-throttle.esm.js", 22 | "require": "./dist/hocuspocus-throttle.cjs", 23 | "types": "./dist/packages/extension-throttle/src/index.d.ts" 24 | } 25 | }, 26 | "files": [ 27 | "src", 28 | "dist" 29 | ], 30 | "dependencies": { 31 | "@hocuspocus/server": "^3.1.1" 32 | }, 33 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 34 | } 35 | -------------------------------------------------------------------------------- /packages/extension-webhook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/extension-webhook", 3 | "version": "3.1.1", 4 | "description": "hocuspocus webhook extension", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "webhook", 9 | "api", 10 | "rest", 11 | "yjs" 12 | ], 13 | "license": "MIT", 14 | "type": "module", 15 | "main": "dist/hocuspocus-webhook.cjs", 16 | "module": "dist/hocuspocus-webhook.esm.js", 17 | "types": "dist/packages/extension-webhook/src/index.d.ts", 18 | "exports": { 19 | "source": { 20 | "import": "./src/index.ts" 21 | }, 22 | "default": { 23 | "import": "./dist/hocuspocus-webhook.esm.js", 24 | "require": "./dist/hocuspocus-webhook.cjs", 25 | "types": "./dist/packages/extension-webhook/src/index.d.ts" 26 | } 27 | }, 28 | "files": [ 29 | "src", 30 | "dist" 31 | ], 32 | "dependencies": { 33 | "@hocuspocus/common": "^3.1.1", 34 | "@hocuspocus/server": "^3.1.1", 35 | "@hocuspocus/transformer": "^3.1.1", 36 | "axios": "^1.6.2" 37 | }, 38 | "peerDependencies": { 39 | "yjs": "^13.6.8" 40 | }, 41 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 42 | } 43 | -------------------------------------------------------------------------------- /packages/provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/provider", 3 | "version": "3.1.1", 4 | "description": "hocuspocus provider", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "websocket", 9 | "provider", 10 | "yjs" 11 | ], 12 | "license": "MIT", 13 | "type": "module", 14 | "main": "dist/hocuspocus-provider.cjs", 15 | "module": "dist/hocuspocus-provider.esm.js", 16 | "types": "dist/packages/provider/src/index.d.ts", 17 | "exports": { 18 | "source": { 19 | "import": "./src/index.ts" 20 | }, 21 | "default": { 22 | "import": "./dist/hocuspocus-provider.esm.js", 23 | "require": "./dist/hocuspocus-provider.cjs", 24 | "types": "./dist/packages/provider/src/index.d.ts" 25 | } 26 | }, 27 | "files": [ 28 | "src", 29 | "dist" 30 | ], 31 | "dependencies": { 32 | "@hocuspocus/common": "^3.1.1", 33 | "@lifeomic/attempt": "^3.0.2", 34 | "lib0": "^0.2.87", 35 | "ws": "^8.17.1" 36 | }, 37 | "peerDependencies": { 38 | "y-protocols": "^1.0.6", 39 | "yjs": "^13.6.8" 40 | }, 41 | "gitHead": "cd788b6a315f608ef531524409abdce1e6790726" 42 | } 43 | -------------------------------------------------------------------------------- /packages/provider/src/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | export default class EventEmitter { 2 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 3 | public callbacks: { [key: string]: Function[] } = {} 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 6 | public on(event: string, fn: Function): this { 7 | if (!this.callbacks[event]) { 8 | this.callbacks[event] = [] 9 | } 10 | 11 | this.callbacks[event].push(fn) 12 | 13 | return this 14 | } 15 | 16 | protected emit(event: string, ...args: any): this { 17 | const callbacks = this.callbacks[event] 18 | 19 | if (callbacks) { 20 | callbacks.forEach(callback => callback.apply(this, args)) 21 | } 22 | 23 | return this 24 | } 25 | 26 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 27 | public off(event: string, fn?: Function): this { 28 | const callbacks = this.callbacks[event] 29 | 30 | if (callbacks) { 31 | if (fn) { 32 | this.callbacks[event] = callbacks.filter(callback => callback !== fn) 33 | } else { 34 | delete this.callbacks[event] 35 | } 36 | } 37 | 38 | return this 39 | } 40 | 41 | removeAllListeners(): void { 42 | this.callbacks = {} 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/provider/src/IncomingMessage.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Decoder} from 'lib0/decoding' 3 | import { 4 | createDecoder, 5 | peekVarString, 6 | readVarUint, 7 | readVarUint8Array, 8 | readVarString, 9 | } from 'lib0/decoding' 10 | import type { 11 | Encoder} from 'lib0/encoding' 12 | import { 13 | createEncoder, 14 | writeVarUint, 15 | writeVarUint8Array, 16 | writeVarString, 17 | length, 18 | } from 'lib0/encoding' 19 | import type { MessageType } from './types.ts' 20 | 21 | export class IncomingMessage { 22 | 23 | data: any 24 | 25 | encoder: Encoder 26 | 27 | decoder: Decoder 28 | 29 | constructor(data: any) { 30 | this.data = data 31 | this.encoder = createEncoder() 32 | this.decoder = createDecoder(new Uint8Array(this.data)) 33 | } 34 | 35 | peekVarString(): string { 36 | return peekVarString(this.decoder) 37 | } 38 | 39 | readVarUint(): MessageType { 40 | return readVarUint(this.decoder) 41 | } 42 | 43 | readVarString(): string { 44 | return readVarString(this.decoder) 45 | } 46 | 47 | readVarUint8Array() { 48 | return readVarUint8Array(this.decoder) 49 | } 50 | 51 | writeVarUint(type: MessageType) { 52 | return writeVarUint(this.encoder, type) 53 | } 54 | 55 | writeVarString(string: string) { 56 | return writeVarString(this.encoder, string) 57 | } 58 | 59 | writeVarUint8Array(data: Uint8Array) { 60 | return writeVarUint8Array(this.encoder, data) 61 | } 62 | 63 | length() { 64 | return length(this.encoder) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/provider/src/MessageSender.ts: -------------------------------------------------------------------------------- 1 | import type { Encoder } from "lib0/encoding"; 2 | import { toUint8Array } from "lib0/encoding"; 3 | import type { ConstructableOutgoingMessage } from "./types.ts"; 4 | 5 | export class MessageSender { 6 | encoder: Encoder; 7 | 8 | message: any; 9 | 10 | constructor(Message: ConstructableOutgoingMessage, args: any = {}) { 11 | this.message = new Message(); 12 | this.encoder = this.message.get(args); 13 | } 14 | 15 | create() { 16 | return toUint8Array(this.encoder); 17 | } 18 | 19 | send(webSocket: any) { 20 | webSocket?.send(this.create()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessage.ts: -------------------------------------------------------------------------------- 1 | import type { Encoder} from 'lib0/encoding' 2 | import { createEncoder, toUint8Array } from 'lib0/encoding' 3 | import type { MessageType, OutgoingMessageArguments, OutgoingMessageInterface } from './types.ts' 4 | 5 | export class OutgoingMessage implements OutgoingMessageInterface { 6 | encoder: Encoder 7 | 8 | type?: MessageType 9 | 10 | constructor() { 11 | this.encoder = createEncoder() 12 | } 13 | 14 | get(args: Partial) { 15 | return args.encoder 16 | } 17 | 18 | toUint8Array() { 19 | return toUint8Array(this.encoder) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/AuthenticationMessage.ts: -------------------------------------------------------------------------------- 1 | import { writeVarString, writeVarUint } from 'lib0/encoding' 2 | import { writeAuthentication } from '@hocuspocus/common' 3 | import type { OutgoingMessageArguments } from '../types.ts' 4 | import { MessageType } from '../types.ts' 5 | import { OutgoingMessage } from '../OutgoingMessage.ts' 6 | 7 | export class AuthenticationMessage extends OutgoingMessage { 8 | type = MessageType.Auth 9 | 10 | description = 'Authentication' 11 | 12 | get(args: Partial) { 13 | if (typeof args.token === 'undefined') { 14 | throw new Error('The authentication message requires `token` as an argument.') 15 | } 16 | 17 | writeVarString(this.encoder, args.documentName!) 18 | writeVarUint(this.encoder, this.type) 19 | writeAuthentication(this.encoder, args.token) 20 | 21 | return this.encoder 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/AwarenessMessage.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from 'lib0/encoding' 2 | import { encodeAwarenessUpdate } from 'y-protocols/awareness' 3 | import type { OutgoingMessageArguments } from '../types.ts' 4 | import { MessageType } from '../types.ts' 5 | import { OutgoingMessage } from '../OutgoingMessage.ts' 6 | 7 | export class AwarenessMessage extends OutgoingMessage { 8 | type = MessageType.Awareness 9 | 10 | description = 'Awareness states update' 11 | 12 | get(args: Partial) { 13 | if (typeof args.awareness === 'undefined') { 14 | throw new Error('The awareness message requires awareness as an argument') 15 | } 16 | 17 | if (typeof args.clients === 'undefined') { 18 | throw new Error('The awareness message requires clients as an argument') 19 | } 20 | 21 | encoding.writeVarString(this.encoder, args.documentName!) 22 | encoding.writeVarUint(this.encoder, this.type) 23 | 24 | let awarenessUpdate 25 | if (args.states === undefined) { 26 | awarenessUpdate = encodeAwarenessUpdate(args.awareness, args.clients) 27 | } else { 28 | awarenessUpdate = encodeAwarenessUpdate(args.awareness, args.clients, args.states) 29 | } 30 | 31 | encoding.writeVarUint8Array(this.encoder, awarenessUpdate) 32 | 33 | return this.encoder 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/CloseMessage.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from 'lib0/encoding' 2 | import type { OutgoingMessageArguments } from '../types.ts' 3 | import { MessageType } from '../types.ts' 4 | import { OutgoingMessage } from '../OutgoingMessage.ts' 5 | 6 | export class CloseMessage extends OutgoingMessage { 7 | type = MessageType.CLOSE 8 | 9 | description = 'Ask the server to close the connection' 10 | 11 | get(args: Partial) { 12 | encoding.writeVarString(this.encoder, args.documentName!) 13 | encoding.writeVarUint(this.encoder, this.type) 14 | 15 | return this.encoder 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/QueryAwarenessMessage.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from 'lib0/encoding' 2 | import type { OutgoingMessageArguments } from '../types.ts' 3 | import { MessageType } from '../types.ts' 4 | import { OutgoingMessage } from '../OutgoingMessage.ts' 5 | 6 | export class QueryAwarenessMessage extends OutgoingMessage { 7 | type = MessageType.QueryAwareness 8 | 9 | description = 'Queries awareness states' 10 | 11 | get(args: Partial) { 12 | 13 | encoding.writeVarString(this.encoder, args.documentName!) 14 | encoding.writeVarUint(this.encoder, this.type) 15 | 16 | return this.encoder 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/StatelessMessage.ts: -------------------------------------------------------------------------------- 1 | import { writeVarString, writeVarUint } from 'lib0/encoding' 2 | import type { OutgoingMessageArguments } from '../types.ts' 3 | import { MessageType } from '../types.ts' 4 | import { OutgoingMessage } from '../OutgoingMessage.ts' 5 | 6 | export class StatelessMessage extends OutgoingMessage { 7 | type = MessageType.Stateless 8 | 9 | description = 'A stateless message' 10 | 11 | get(args: Partial) { 12 | writeVarString(this.encoder, args.documentName!) 13 | writeVarUint(this.encoder, this.type) 14 | writeVarString(this.encoder, args.payload ?? '') 15 | 16 | return this.encoder 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/SyncStepOneMessage.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from 'lib0/encoding' 2 | import * as syncProtocol from 'y-protocols/sync' 3 | import type { OutgoingMessageArguments } from '../types.ts' 4 | import { MessageType } from '../types.ts' 5 | import { OutgoingMessage } from '../OutgoingMessage.ts' 6 | 7 | export class SyncStepOneMessage extends OutgoingMessage { 8 | type = MessageType.Sync 9 | 10 | description = 'First sync step' 11 | 12 | get(args: Partial) { 13 | if (typeof args.document === 'undefined') { 14 | throw new Error('The sync step one message requires document as an argument') 15 | } 16 | 17 | encoding.writeVarString(this.encoder, args.documentName!) 18 | encoding.writeVarUint(this.encoder, this.type) 19 | syncProtocol.writeSyncStep1(this.encoder, args.document) 20 | 21 | return this.encoder 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/SyncStepTwoMessage.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from 'lib0/encoding' 2 | import * as syncProtocol from 'y-protocols/sync' 3 | import type { OutgoingMessageArguments } from '../types.ts' 4 | import { MessageType } from '../types.ts' 5 | import { OutgoingMessage } from '../OutgoingMessage.ts' 6 | 7 | export class SyncStepTwoMessage extends OutgoingMessage { 8 | type = MessageType.Sync 9 | 10 | description = 'Second sync step' 11 | 12 | get(args: Partial) { 13 | if (typeof args.document === 'undefined') { 14 | throw new Error('The sync step two message requires document as an argument') 15 | } 16 | 17 | encoding.writeVarString(this.encoder, args.documentName!) 18 | encoding.writeVarUint(this.encoder, this.type) 19 | syncProtocol.writeSyncStep2(this.encoder, args.document) 20 | 21 | return this.encoder 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/provider/src/OutgoingMessages/UpdateMessage.ts: -------------------------------------------------------------------------------- 1 | import { writeVarString, writeVarUint } from 'lib0/encoding' 2 | import { writeUpdate } from 'y-protocols/sync' 3 | import type { OutgoingMessageArguments } from '../types.ts' 4 | import { MessageType } from '../types.ts' 5 | import { OutgoingMessage } from '../OutgoingMessage.ts' 6 | 7 | export class UpdateMessage extends OutgoingMessage { 8 | type = MessageType.Sync 9 | 10 | description = 'A document update' 11 | 12 | get(args: Partial) { 13 | writeVarString(this.encoder, args.documentName!) 14 | writeVarUint(this.encoder, this.type) 15 | 16 | writeUpdate(this.encoder, args.update) 17 | 18 | return this.encoder 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/provider/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HocuspocusProvider.ts' 2 | export * from './HocuspocusProviderWebsocket.ts' 3 | export * from './types.ts' 4 | -------------------------------------------------------------------------------- /packages/provider/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Encoder } from 'lib0/encoding' 2 | import type { Event, MessageEvent } from 'ws' 3 | import type { Awareness } from 'y-protocols/awareness' 4 | import type * as Y from 'yjs' 5 | import type { CloseEvent } from '@hocuspocus/common' 6 | import type { IncomingMessage } from './IncomingMessage.ts' 7 | import type { OutgoingMessage } from './OutgoingMessage.ts' 8 | import type { AuthenticationMessage } from './OutgoingMessages/AuthenticationMessage.ts' 9 | import type { AwarenessMessage } from './OutgoingMessages/AwarenessMessage.ts' 10 | import type { QueryAwarenessMessage } from './OutgoingMessages/QueryAwarenessMessage.ts' 11 | import type { SyncStepOneMessage } from './OutgoingMessages/SyncStepOneMessage.ts' 12 | import type { SyncStepTwoMessage } from './OutgoingMessages/SyncStepTwoMessage.ts' 13 | import type { UpdateMessage } from './OutgoingMessages/UpdateMessage.ts' 14 | 15 | export enum MessageType { 16 | Sync = 0, 17 | Awareness = 1, 18 | Auth = 2, 19 | QueryAwareness = 3, 20 | Stateless = 5, 21 | CLOSE = 7, 22 | SyncStatus = 8, 23 | } 24 | 25 | export enum WebSocketStatus { 26 | Connecting = 'connecting', 27 | Connected = 'connected', 28 | Disconnected = 'disconnected', 29 | } 30 | 31 | export interface OutgoingMessageInterface { 32 | encoder: Encoder 33 | type?: MessageType 34 | } 35 | 36 | export interface OutgoingMessageArguments { 37 | documentName: string, 38 | token: string, 39 | document: Y.Doc, 40 | awareness: Awareness, 41 | clients: number[], 42 | states: Map, 43 | update: any, 44 | payload: string, 45 | encoder: Encoder, 46 | } 47 | 48 | export interface Constructable { 49 | new(...args: any) : T 50 | } 51 | 52 | export type ConstructableOutgoingMessage = 53 | Constructable | 54 | Constructable | 55 | Constructable | 56 | Constructable | 57 | Constructable | 58 | Constructable 59 | 60 | export type onAuthenticationFailedParameters = { 61 | reason: string, 62 | } 63 | 64 | export type onAuthenticatedParameters = { 65 | scope: 'read-write' | 'readonly', 66 | } 67 | 68 | export type onOpenParameters = { 69 | event: Event, 70 | } 71 | 72 | export type onMessageParameters = { 73 | event: MessageEvent, 74 | message: IncomingMessage, 75 | } 76 | 77 | export type onOutgoingMessageParameters = { 78 | message: OutgoingMessage, 79 | } 80 | 81 | export type onStatusParameters = { 82 | status: WebSocketStatus, 83 | } 84 | 85 | export type onSyncedParameters = { 86 | state: boolean, 87 | } 88 | 89 | export type onUnsyncedChangesParameters = { 90 | number: number, 91 | } 92 | 93 | export type onDisconnectParameters = { 94 | event: CloseEvent, 95 | } 96 | 97 | export type onCloseParameters = { 98 | event: CloseEvent, 99 | } 100 | 101 | export type onAwarenessUpdateParameters = { 102 | states: StatesArray 103 | } 104 | 105 | export type onAwarenessChangeParameters = { 106 | states: StatesArray 107 | } 108 | 109 | export type onStatelessParameters = { 110 | payload: string 111 | } 112 | 113 | export type StatesArray = { clientId: number, [key: string | number]: any }[] 114 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # @hocuspocus/server 2 | [![Version](https://img.shields.io/npm/v/@hocuspocus/server.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/server) 3 | [![Downloads](https://img.shields.io/npm/dm/@hocuspocus/server.svg)](https://npmcharts.com/compare/tiptap?minimal=true) 4 | [![License](https://img.shields.io/npm/l/@hocuspocus/server.svg)](https://www.npmjs.com/package/@hocuspocus/server) 5 | [![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) 6 | 7 | ## Introduction 8 | Hocuspocus is an opinionated collaborative editing backend for [Tiptap](https://github.com/ueberdosis/tiptap) – based on [Y.js](https://github.com/yjs/yjs), a CRDT framework with a powerful abstraction of shared data. 9 | 10 | ## Official Documentation 11 | Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus). 12 | 13 | ## License 14 | Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md). 15 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/server", 3 | "description": "plug & play collaboration backend", 4 | "version": "3.1.1", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "yjs", 9 | "yjs-websocket", 10 | "prosemirror" 11 | ], 12 | "license": "MIT", 13 | "type": "module", 14 | "main": "dist/hocuspocus-server.cjs", 15 | "module": "dist/hocuspocus-server.esm.js", 16 | "types": "dist/packages/server/src/index.d.ts", 17 | "exports": { 18 | "source": { 19 | "import": "./src/index.ts" 20 | }, 21 | "default": { 22 | "import": "./dist/hocuspocus-server.esm.js", 23 | "require": "./dist/hocuspocus-server.cjs", 24 | "types": "./dist/packages/server/src/index.d.ts" 25 | } 26 | }, 27 | "files": [ 28 | "src", 29 | "dist" 30 | ], 31 | "dependencies": { 32 | "@hocuspocus/common": "^3.1.1", 33 | "async-lock": "^1.3.1", 34 | "kleur": "^4.1.4", 35 | "lib0": "^0.2.47", 36 | "uuid": "^11.0.3", 37 | "ws": "^8.5.0" 38 | }, 39 | "devDependencies": { 40 | "@types/async-lock": "^1.1.3", 41 | "@types/uuid": "^10.0.0", 42 | "@types/ws": "^8.5.3" 43 | }, 44 | "peerDependencies": { 45 | "y-protocols": "^1.0.6", 46 | "yjs": "^13.6.8" 47 | }, 48 | "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d" 49 | } 50 | -------------------------------------------------------------------------------- /packages/server/src/DirectConnection.ts: -------------------------------------------------------------------------------- 1 | import { URLSearchParams } from 'url' 2 | import type Document from './Document.ts' 3 | import type { Hocuspocus } from './Hocuspocus.ts' 4 | import type { DirectConnection as DirectConnectionInterface } from './types.ts' 5 | 6 | export class DirectConnection implements DirectConnectionInterface { 7 | document: Document | null = null 8 | 9 | instance!: Hocuspocus 10 | 11 | context: any 12 | 13 | /** 14 | * Constructor. 15 | */ 16 | constructor( 17 | document: Document, 18 | instance: Hocuspocus, 19 | context?: any, 20 | ) { 21 | this.document = document 22 | this.instance = instance 23 | this.context = context 24 | 25 | this.document.addDirectConnection() 26 | } 27 | 28 | async transact(transaction: (document: Document) => void) { 29 | if (!this.document) { 30 | throw new Error('direct connection closed') 31 | } 32 | 33 | transaction(this.document) 34 | 35 | await this.instance.storeDocumentHooks(this.document, { 36 | clientsCount: this.document.getConnectionsCount(), 37 | context: this.context, 38 | document: this.document, 39 | documentName: this.document.name, 40 | instance: this.instance, 41 | requestHeaders: {}, 42 | requestParameters: new URLSearchParams(), 43 | socketId: 'server', 44 | }, true) 45 | } 46 | 47 | async disconnect() { 48 | if (this.document) { 49 | this.document?.removeDirectConnection() 50 | 51 | await this.instance.storeDocumentHooks(this.document, { 52 | clientsCount: this.document.getConnectionsCount(), 53 | context: this.context, 54 | document: this.document, 55 | documentName: this.document.name, 56 | instance: this.instance, 57 | requestHeaders: {}, 58 | requestParameters: new URLSearchParams(), 59 | socketId: 'server', 60 | }, true) 61 | 62 | // If the direct connection was the only connection to the document 63 | // then we should trigger the onDisconnect hook for 64 | // this doc and unload the document 65 | if (this.document.getConnectionsCount() === 0) { 66 | await this.instance.hooks('onDisconnect', { 67 | instance: this.instance, 68 | clientsCount: this.document.getConnectionsCount(), 69 | context: this.context, 70 | document: this.document, 71 | socketId: 'server', 72 | documentName: this.document.name, 73 | requestHeaders: {}, 74 | requestParameters: new URLSearchParams(), 75 | }) 76 | 77 | await this.instance.unloadDocument(this.document) 78 | } 79 | 80 | this.document = null 81 | } 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /packages/server/src/IncomingMessage.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Decoder} from 'lib0/decoding' 3 | import { 4 | createDecoder, 5 | readVarUint, 6 | readVarUint8Array, 7 | readVarString, 8 | } from 'lib0/decoding' 9 | import type { 10 | Encoder} from 'lib0/encoding' 11 | import { 12 | createEncoder, 13 | toUint8Array, 14 | writeVarUint, 15 | writeVarString, 16 | length, 17 | } from 'lib0/encoding' 18 | import type { MessageType } from './types.ts' 19 | 20 | export class IncomingMessage { 21 | /** 22 | * Access to the received message. 23 | */ 24 | decoder: Decoder 25 | 26 | /** 27 | * Private encoder; can be undefined. 28 | * 29 | * Lazy creation of the encoder speeds up IncomingMessages that need only a decoder. 30 | */ 31 | private encoderInternal?: Encoder 32 | 33 | constructor(input: any) { 34 | if (!(input instanceof Uint8Array)) { 35 | input = new Uint8Array(input) 36 | } 37 | 38 | this.decoder = createDecoder(input) 39 | } 40 | 41 | get encoder() { 42 | if (!this.encoderInternal) { 43 | this.encoderInternal = createEncoder() 44 | } 45 | return this.encoderInternal 46 | } 47 | 48 | readVarUint8Array() { 49 | return readVarUint8Array(this.decoder) 50 | } 51 | 52 | peekVarUint8Array() { 53 | const { pos } = this.decoder 54 | const result = readVarUint8Array(this.decoder) 55 | this.decoder.pos = pos 56 | return result 57 | } 58 | 59 | readVarUint() { 60 | return readVarUint(this.decoder) 61 | } 62 | 63 | readVarString() { 64 | return readVarString(this.decoder) 65 | } 66 | 67 | toUint8Array() { 68 | return toUint8Array(this.encoder) 69 | } 70 | 71 | writeVarUint(type: MessageType) { 72 | writeVarUint(this.encoder, type) 73 | } 74 | 75 | writeVarString(string: string) { 76 | writeVarString(this.encoder, string) 77 | } 78 | 79 | get length(): number { 80 | return length(this.encoder) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Connection.ts' 2 | export * from './Document.ts' 3 | export * from './Hocuspocus.ts' 4 | export * from './IncomingMessage.ts' 5 | export * from './MessageReceiver.ts' 6 | export * from './OutgoingMessage.ts' 7 | export * from './Server.ts' 8 | export * from './types.ts' 9 | export * from './util/debounce.ts' 10 | -------------------------------------------------------------------------------- /packages/server/src/util/debounce.ts: -------------------------------------------------------------------------------- 1 | export const useDebounce = () => { 2 | const timers: Map = new Map() 8 | 9 | const debounce = ( 10 | id: string, 11 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 12 | func: Function, 13 | debounce: number, 14 | maxDebounce: number, 15 | ) => { 16 | const old = timers.get(id) 17 | const start = old?.start || Date.now() 18 | 19 | const run = () => { 20 | timers.delete(id) 21 | return func() 22 | } 23 | 24 | if (old?.timeout) { 25 | clearTimeout(old.timeout) 26 | } 27 | 28 | if (debounce === 0) { 29 | return run() 30 | } 31 | 32 | if (Date.now() - start >= maxDebounce) { 33 | return run() 34 | } 35 | 36 | timers.set(id, { 37 | start, 38 | timeout: setTimeout(run, debounce), 39 | func: run, 40 | }) 41 | } 42 | 43 | const executeNow = (id: string) => { 44 | const old = timers.get(id) 45 | if (old) { 46 | clearTimeout(old.timeout) 47 | return old.func() 48 | } 49 | } 50 | 51 | const isDebounced = (id: string): boolean => { 52 | return timers.has(id) 53 | } 54 | 55 | return { debounce, isDebounced, executeNow } 56 | } 57 | -------------------------------------------------------------------------------- /packages/server/src/util/getParameters.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'http' 2 | import { URLSearchParams } from 'url' 3 | 4 | /** 5 | * Get parameters by the given request 6 | */ 7 | export function getParameters(request?: Pick): URLSearchParams { 8 | const query = request?.url?.split('?') || [] 9 | return new URLSearchParams(query[1] ? query[1] : '') 10 | } 11 | -------------------------------------------------------------------------------- /packages/transformer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/transformer", 3 | "version": "3.1.1", 4 | "description": "hocuspocus transformation utilities", 5 | "homepage": "https://hocuspocus.dev", 6 | "keywords": [ 7 | "hocuspocus", 8 | "transformer", 9 | "yjs" 10 | ], 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "dist/hocuspocus-transformer.cjs", 14 | "module": "dist/hocuspocus-transformer.esm.js", 15 | "types": "dist/packages/transformer/src/index.d.ts", 16 | "exports": { 17 | "source": { 18 | "import": "./src/index.ts" 19 | }, 20 | "default": { 21 | "import": "./dist/hocuspocus-transformer.esm.js", 22 | "require": "./dist/hocuspocus-transformer.cjs", 23 | "types": "./dist/packages/transformer/src/index.d.ts" 24 | } 25 | }, 26 | "files": [ 27 | "src", 28 | "dist" 29 | ], 30 | "dependencies": { 31 | "@tiptap/starter-kit": "3.0.0-beta.5" 32 | }, 33 | "peerDependencies": { 34 | "@tiptap/core": "3.0.0-beta.5", 35 | "@tiptap/pm": "3.0.0-beta.5", 36 | "y-prosemirror": "^1.2.1", 37 | "yjs": "^13.6.8" 38 | }, 39 | "devDependencies": { 40 | "@tiptap/pm": "3.0.0-beta.5" 41 | }, 42 | "gitHead": "8f2e9df95de9968d70622ea8697c303a333b6cb3" 43 | } 44 | -------------------------------------------------------------------------------- /packages/transformer/src/Prosemirror.ts: -------------------------------------------------------------------------------- 1 | import { Doc, applyUpdate, encodeStateAsUpdate } from 'yjs' 2 | // @ts-ignore 3 | import { yDocToProsemirrorJSON, prosemirrorJSONToYDoc } from 'y-prosemirror' 4 | import { Schema } from '@tiptap/pm/model' 5 | import type { Transformer } from './types.ts' 6 | 7 | class Prosemirror implements Transformer { 8 | 9 | defaultSchema: Schema = new Schema({ 10 | nodes: { 11 | text: {}, 12 | doc: { content: 'text*' }, 13 | }, 14 | }) 15 | 16 | schema(schema: Schema): Prosemirror { 17 | this.defaultSchema = schema 18 | 19 | return this 20 | } 21 | 22 | fromYdoc(document: Doc, fieldName?: string | Array): any { 23 | const data = {} 24 | 25 | // allow a single field name 26 | if (typeof fieldName === 'string') { 27 | return yDocToProsemirrorJSON(document, fieldName) 28 | } 29 | 30 | // default to all available fields if the given field name is empty 31 | if (fieldName === undefined || fieldName.length === 0) { 32 | fieldName = Array.from(document.share.keys()) 33 | } 34 | 35 | fieldName.forEach(field => { 36 | // @ts-ignore 37 | data[field] = yDocToProsemirrorJSON(document, field) 38 | }) 39 | 40 | return data 41 | } 42 | 43 | toYdoc(document: any, fieldName: string | Array = 'prosemirror', schema?: Schema): Doc { 44 | if (!document) { 45 | throw new Error(`You’ve passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`) 46 | } 47 | 48 | // allow a single field name 49 | if (typeof fieldName === 'string') { 50 | return prosemirrorJSONToYDoc(schema || this.defaultSchema, document, fieldName) 51 | } 52 | 53 | const ydoc = new Doc() 54 | 55 | fieldName.forEach(field => { 56 | const update = encodeStateAsUpdate( 57 | prosemirrorJSONToYDoc(schema || this.defaultSchema, document, field), 58 | ) 59 | 60 | applyUpdate(ydoc, update) 61 | }) 62 | 63 | return ydoc 64 | } 65 | 66 | } 67 | 68 | export const ProsemirrorTransformer = new Prosemirror() 69 | -------------------------------------------------------------------------------- /packages/transformer/src/Tiptap.ts: -------------------------------------------------------------------------------- 1 | import type { Doc } from 'yjs' 2 | // @ts-ignore 3 | import type { Extensions} from '@tiptap/core' 4 | import { getSchema } from '@tiptap/core' 5 | import StarterKit from '@tiptap/starter-kit' 6 | import type { Transformer } from './types.ts' 7 | import { ProsemirrorTransformer } from './Prosemirror.ts' 8 | 9 | export class Tiptap implements Transformer { 10 | defaultExtensions: Extensions = [ 11 | StarterKit, 12 | ] 13 | 14 | extensions(extensions: Extensions): Tiptap { 15 | this.defaultExtensions = extensions 16 | 17 | return this 18 | } 19 | 20 | fromYdoc(document: Doc, fieldName?: string | Array): any { 21 | return ProsemirrorTransformer.fromYdoc(document, fieldName) 22 | } 23 | 24 | toYdoc(document: any, fieldName: string | Array = 'default', extensions?: Extensions): Doc { 25 | return ProsemirrorTransformer.toYdoc( 26 | document, 27 | fieldName, 28 | getSchema(extensions || this.defaultExtensions), 29 | ) 30 | } 31 | 32 | } 33 | 34 | export const TiptapTransformer = new Tiptap() 35 | -------------------------------------------------------------------------------- /packages/transformer/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Prosemirror.ts' 2 | export * from './Tiptap.ts' 3 | export * from './types.ts' 4 | -------------------------------------------------------------------------------- /packages/transformer/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Doc } from 'yjs' 2 | 3 | export interface Transformer { 4 | fromYdoc: (document: Doc, fieldName?: string | Array) => any, 5 | toYdoc: (document: any, fieldName: string) => Doc, 6 | } 7 | -------------------------------------------------------------------------------- /playground/README.md: -------------------------------------------------------------------------------- 1 | # Hocuspocus Playground 2 | 3 | ``` 4 | Hi! 5 | 6 | Welcome to the playground. This is mainly meant for internal usage during Hocuspocus development, 7 | but feel free to fiddle around here and find a few examples of how things can work. 8 | 9 | Please note that there are different packages used in frontend / backend folders, so you should 10 | run `npm install` in both if you get any errors. 11 | 12 | The playground is importing @hocuspocus packages from the packages folder, so you need to build them first. 13 | Run `npm run build:packages` in the repo root to do that. 14 | If you want changes inside the packages folder to compile live, you can use rollup: `rollup -c -w`. 15 | 16 | (see also docs/contributing.md) 17 | 18 | You can run `npm run playground` in the repository root, which will spin up a development server on 19 | http://127.0.0.1:3000. 20 | 21 | If you have any questions, feel free to join our discord or ask on Github (links can be found in the 22 | repo README.md one folder up). 23 | ``` 24 | -------------------------------------------------------------------------------- /playground/backend/.gitignore: -------------------------------------------------------------------------------- 1 | /database 2 | /dashboard 3 | /db.sqlite 4 | -------------------------------------------------------------------------------- /playground/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/server-demos", 3 | "packageManager": "npm@22.7.0", 4 | "version": "3.1.1", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "start": "node --experimental-transform-types", 9 | "dev": "nodemon --inspect -e ts --watch ./ --watch ../../packages --exec npm start" 10 | }, 11 | "dependencies": { 12 | "@hocuspocus/extension-logger": "^3.1.1", 13 | "@hocuspocus/extension-redis": "^3.1.1", 14 | "@hocuspocus/extension-sqlite": "^3.1.1", 15 | "@hocuspocus/extension-webhook": "^3.1.1", 16 | "@hocuspocus/server": "^3.1.1", 17 | "@hocuspocus/transformer": "^3.1.1", 18 | "@tiptap/core": "3.0.0-beta.5", 19 | "@tiptap/pm": "3.0.0-beta.5", 20 | "@tiptap/starter-kit": "3.0.0-beta.5", 21 | "@types/express": "^4.17.13", 22 | "@types/express-ws": "^3.0.1", 23 | "@types/node": "^16.11.11", 24 | "cors": "^2.8.5", 25 | "express": "^4.17.3", 26 | "express-ws": "^5.0.2", 27 | "jsonwebtoken": "^9.0.0", 28 | "koa": "^2.13.4", 29 | "koa-easy-ws": "^1.3.1", 30 | "yjs": "^13.6.4" 31 | }, 32 | "devDependencies": { 33 | "@types/jsonwebtoken": "^9.0.1", 34 | "@types/koa": "^2.13.5", 35 | "nodemon": "^3.1.7" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /playground/backend/src/default.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@hocuspocus/extension-logger"; 2 | import { SQLite } from "@hocuspocus/extension-sqlite"; 3 | import { Server } from "@hocuspocus/server"; 4 | 5 | const server = new Server({ 6 | port: 1234, 7 | address: "127.0.0.1", 8 | name: "hocuspocus-fra1-01", 9 | extensions: [ 10 | new Logger(), 11 | new SQLite({ 12 | database: "db.sqlite", 13 | }), 14 | ], 15 | 16 | // async onAuthenticate(data) { 17 | // if (data.token !== 'my-access-token') { 18 | // throw new Error('Incorrect access token') 19 | // } 20 | // }, 21 | 22 | // Test error handling 23 | // async onConnect(data) { 24 | // throw new Error('CRASH') 25 | // }, 26 | 27 | // async onConnect(data) { 28 | // await new Promise((resolve, reject) => setTimeout(() => { 29 | // // @ts-ignore 30 | // reject() 31 | // }, 1337)) 32 | // }, 33 | 34 | // async onConnect(data) { 35 | // await new Promise((resolve, reject) => setTimeout(() => { 36 | // // @ts-ignore 37 | // resolve() 38 | // }, 1337)) 39 | // }, 40 | 41 | // Intercept HTTP requests 42 | // onRequest(data) { 43 | // return new Promise((resolve, reject) => { 44 | // const { response } = data 45 | // // Respond with your custom content 46 | // response.writeHead(200, { 'Content-Type': 'text/plain' }) 47 | // response.end('This is my custom response, yay!') 48 | 49 | // // Rejecting the promise will stop the chain and no further 50 | // // onRequest hooks are run 51 | // return reject() 52 | // }) 53 | // }, 54 | }); 55 | 56 | // server.enableMessageLogging() 57 | 58 | server.listen(); 59 | -------------------------------------------------------------------------------- /playground/backend/src/express.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@hocuspocus/extension-logger"; 2 | import { Hocuspocus } from "@hocuspocus/server"; 3 | import express from "express"; 4 | import expressWebsockets from "express-ws"; 5 | 6 | const hocuspocus = new Hocuspocus({ 7 | extensions: [new Logger()], 8 | }); 9 | 10 | const { app } = expressWebsockets(express()); 11 | 12 | app.get("/", (request, response) => { 13 | response.send("Hello World!"); 14 | }); 15 | 16 | app.ws("/", (websocket, request: any) => { 17 | const context = { user_id: 1234 }; 18 | hocuspocus.handleConnection(websocket, request, context); 19 | }); 20 | 21 | app.listen(1234, () => console.log("Listening on http://127.0.0.1:1234…")); 22 | -------------------------------------------------------------------------------- /playground/backend/src/koa.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@hocuspocus/extension-logger"; 2 | import { Hocuspocus } from "@hocuspocus/server"; 3 | // @ts-nocheck 4 | import Koa from "koa"; 5 | import websocket from "koa-easy-ws"; 6 | 7 | const hocuspocus = new Hocuspocus({ 8 | extensions: [new Logger()], 9 | }); 10 | 11 | const app = new Koa(); 12 | 13 | app.use(websocket()); 14 | 15 | app.use(async (ctx, next) => { 16 | const ws = await ctx.ws(); 17 | 18 | hocuspocus.handleConnection( 19 | ws, 20 | ctx.req, 21 | // additional data (optional) 22 | { 23 | user_id: 1234, 24 | }, 25 | ); 26 | }); 27 | 28 | app.listen(1234); 29 | -------------------------------------------------------------------------------- /playground/backend/src/load-document.ts: -------------------------------------------------------------------------------- 1 | import type { onLoadDocumentPayload } from '@hocuspocus/server' 2 | import { Server } from '@hocuspocus/server' 3 | import { Logger } from '@hocuspocus/extension-logger' 4 | import { TiptapTransformer } from '@hocuspocus/transformer' 5 | import { SQLite } from '@hocuspocus/extension-sqlite' 6 | 7 | const getProseMirrorJSON = (text: string) => { 8 | return { 9 | type: 'doc', 10 | content: [ 11 | { 12 | type: 'paragraph', 13 | content: [ 14 | { 15 | type: 'text', 16 | text, 17 | }, 18 | ], 19 | }, 20 | ], 21 | } 22 | } 23 | 24 | const server = new Server({ 25 | port: 1234, 26 | extensions: [ 27 | new Logger(), 28 | new SQLite({ 29 | database: 'db.sqlite', 30 | }), 31 | ], 32 | 33 | async onConnect(data) { 34 | await new Promise(resolve => setTimeout(() => { 35 | // @ts-ignore 36 | resolve() 37 | }, 1337)) 38 | }, 39 | 40 | async onLoadDocument(data: onLoadDocumentPayload) { 41 | if (data.document.isEmpty('default')) { 42 | const defaultField = TiptapTransformer.toYdoc( 43 | getProseMirrorJSON('What is love?'), 44 | 'default', 45 | ) 46 | 47 | data.document.merge(defaultField) 48 | } 49 | 50 | if (data.document.isEmpty('secondary')) { 51 | const secondaryField = TiptapTransformer.toYdoc( 52 | getProseMirrorJSON('Baby don\'t hurt me…'), 53 | 'secondary', 54 | ) 55 | 56 | data.document.merge(secondaryField) 57 | } 58 | 59 | return data.document 60 | }, 61 | }) 62 | 63 | server.listen() 64 | -------------------------------------------------------------------------------- /playground/backend/src/redis.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '@hocuspocus/server' 2 | import { Logger } from '@hocuspocus/extension-logger' 3 | import { Redis } from '@hocuspocus/extension-redis' 4 | import { SQLite } from '@hocuspocus/extension-sqlite' 5 | 6 | const server = new Server({ 7 | port: 1234, 8 | name: 'redis-1', 9 | extensions: [ 10 | new Logger(), 11 | new Redis({ 12 | host: '127.0.0.1', 13 | port: 6379, 14 | }), 15 | new SQLite(), 16 | ], 17 | }) 18 | 19 | server.listen() 20 | 21 | const anotherServer = new Server({ 22 | port: 1235, 23 | name: 'redis-2', 24 | extensions: [ 25 | new Logger(), 26 | new Redis({ 27 | host: '127.0.0.1', 28 | port: 6379, 29 | }), 30 | new SQLite(), 31 | ], 32 | 33 | // onAwarenessUpdate: async ({ documentName, states }) => { 34 | // console.log('onAwarenessUpdate', documentName, states) 35 | // }, 36 | }) 37 | 38 | anotherServer.listen() 39 | -------------------------------------------------------------------------------- /playground/backend/src/slow.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '@hocuspocus/server' 2 | import { Logger } from '@hocuspocus/extension-logger' 3 | import { SQLite } from '@hocuspocus/extension-sqlite' 4 | 5 | const server = new Server({ 6 | port: 1234, 7 | extensions: [ 8 | new Logger(), 9 | new SQLite({ 10 | database: 'db.sqlite', 11 | }), 12 | ], 13 | 14 | async onConnect(data) { 15 | // simulate a very slow authentication process that takes 10 seconds (or more if you want to type more) 16 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 17 | await new Promise((resolve: Function) => { 18 | setTimeout(() => { resolve() }, 10000) 19 | }) 20 | 21 | return true 22 | }, 23 | }) 24 | 25 | server.listen() 26 | -------------------------------------------------------------------------------- /playground/backend/src/tiptapcollab.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import expressWebsockets from 'express-ws' 3 | // @ts-ignore 4 | import jsonwebtoken from 'jsonwebtoken' 5 | // @ts-ignore 6 | import cors from 'cors' 7 | 8 | const { app } = expressWebsockets(express()) 9 | app.use(cors()) 10 | 11 | app.get('/', (request, response) => { 12 | // do NOT do this in production, this is just for demo purposes. The secret MUST be stored on the server and never reach the client side. 13 | const { secret } = request.query 14 | 15 | const jwt = jsonwebtoken.sign({ 16 | allowedDocumentNames: ['test1', 'test2'], 17 | }, secret?.toString() ?? '') 18 | 19 | response.send(jwt) 20 | }) 21 | app.listen(1234, () => console.log('Listening on http://127.0.0.1:1234…')) 22 | -------------------------------------------------------------------------------- /playground/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /playground/frontend/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [3.1.1](https://github.com/ueberdosis/hocuspocus/compare/v3.1.1-rc.1...v3.1.1) (2025-05-10) 7 | 8 | **Note:** Version bump only for package @hocuspocus/frontend-demo 9 | 10 | 11 | 12 | 13 | 14 | ## [3.1.1-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v3.1.1-rc.0...v3.1.1-rc.1) (2025-05-08) 15 | 16 | **Note:** Version bump only for package @hocuspocus/frontend-demo 17 | 18 | 19 | 20 | 21 | 22 | ## [3.1.1-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.1.0...v3.1.1-rc.0) (2025-04-30) 23 | 24 | **Note:** Version bump only for package @hocuspocus/frontend-demo 25 | 26 | 27 | 28 | 29 | 30 | # [3.1.0](https://github.com/ueberdosis/hocuspocus/compare/v3.1.0-rc.0...v3.1.0) (2025-04-29) 31 | 32 | **Note:** Version bump only for package @hocuspocus/frontend-demo 33 | 34 | 35 | 36 | 37 | 38 | # [3.1.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.8-rc.0...v3.1.0-rc.0) (2025-04-28) 39 | 40 | **Note:** Version bump only for package @hocuspocus/frontend-demo 41 | 42 | 43 | 44 | 45 | 46 | ## [3.0.8-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.7-rc.0...v3.0.8-rc.0) (2025-04-09) 47 | 48 | **Note:** Version bump only for package @hocuspocus/frontend-demo 49 | 50 | 51 | 52 | 53 | 54 | ## [3.0.7-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.6-rc.0...v3.0.7-rc.0) (2025-04-09) 55 | 56 | **Note:** Version bump only for package @hocuspocus/frontend-demo 57 | 58 | 59 | 60 | 61 | 62 | ## [3.0.6-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.5-rc.0...v3.0.6-rc.0) (2025-03-28) 63 | 64 | **Note:** Version bump only for package @hocuspocus/frontend-demo 65 | 66 | 67 | 68 | 69 | 70 | ## [3.0.5-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.4-rc.0...v3.0.5-rc.0) (2025-03-28) 71 | 72 | **Note:** Version bump only for package @hocuspocus/frontend-demo 73 | -------------------------------------------------------------------------------- /playground/frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /playground/frontend/app/SocketContext.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; 4 | import { createContext } from "react"; 5 | 6 | export const SocketContext = createContext( 7 | null, 8 | ); 9 | -------------------------------------------------------------------------------- /playground/frontend/app/articles/[slug]/ArticleEditor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SocketContext } from "@/app/SocketContext"; 4 | import CollaborativeEditor from "@/app/articles/[slug]/CollaborativeEditor"; 5 | import { HocuspocusProvider } from "@hocuspocus/provider"; 6 | // import { TiptapCollabProvider } from "@tiptap-cloud/provider"; 7 | import { useContext, useEffect, useState } from "react"; 8 | import CollaborationStatus from "@/app/articles/[slug]/CollaborationStatus"; 9 | 10 | export default function ArticleEditor({ slug }: { slug: string }) { 11 | const socket = useContext(SocketContext); 12 | 13 | const [provider, setProvider] = useState(); 14 | 15 | useEffect(() => { 16 | if (!socket) return; 17 | 18 | // const _p = new TiptapCollabProvider({ 19 | const _p = new HocuspocusProvider({ 20 | websocketProvider: socket, 21 | name: slug, 22 | onOpen: (data) => console.log("onOpen!", data), 23 | onClose: (data) => console.log("onClose!", data), 24 | onAuthenticated: (data) => console.log("onAuthenticated!", data), 25 | onAuthenticationFailed: (data) => 26 | console.log("onAuthenticationFailed", data), 27 | onUnsyncedChanges: (data) => 28 | console.log("onUnsyncedChanges", data) 29 | }); 30 | 31 | setProvider(_p); 32 | 33 | return () => { 34 | _p.detach(); 35 | }; 36 | }, [socket, slug]); 37 | 38 | if (!provider) { 39 | return <>; 40 | } 41 | 42 | // you need to attach here, to make sure the connection gets properly established due to React strict-mode re-run of hooks 43 | provider.attach(); 44 | 45 | return ( 46 |
47 |

Editor for article #{slug}

48 | 49 | 50 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /playground/frontend/app/articles/[slug]/CollaborationStatus.tsx: -------------------------------------------------------------------------------- 1 | import type {HocuspocusProvider, onUnsyncedChangesParameters} from "@hocuspocus/provider"; 2 | import {useState} from "react"; 3 | 4 | const CollaborationStatus = (props: { 5 | provider: HocuspocusProvider; 6 | }) => { 7 | const { provider } = props; 8 | 9 | const [unsyncedChanges, setUnsyncedChanges] = useState(0); 10 | 11 | provider.on('unsyncedChanges', (changes: onUnsyncedChangesParameters) => setUnsyncedChanges(changes.number)) 12 | 13 | return
14 |

Socket status: {provider.configuration.websocketProvider.status} 15 | / Provider status: {provider.isAttached ? 'attached' : 'detached'} 16 |

17 | 18 |

Unsynced changes: {unsyncedChanges}

19 | 20 |
21 |
22 |

Socket

23 |
24 | 25 | 26 |
27 |
28 | 29 |
30 |

Provider

31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 | }; 39 | 40 | export default CollaborationStatus; 41 | -------------------------------------------------------------------------------- /playground/frontend/app/articles/[slug]/CollaborativeEditor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { HocuspocusProvider } from "@hocuspocus/provider"; 4 | import { Collaboration } from "@tiptap/extension-collaboration"; 5 | import { CollaborationCaret } from "@tiptap/extension-collaboration-caret"; 6 | import { EditorContent, useEditor } from "@tiptap/react"; 7 | import { StarterKit } from "@tiptap/starter-kit"; 8 | import * as Y from "yjs"; 9 | 10 | const initialContent = [ 11 | 1, 3, 223, 175, 255, 141, 2, 0, 7, 1, 7, 100, 101, 102, 97, 117, 108, 116, 3, 12 | 9, 112, 97, 114, 97, 103, 114, 97, 112, 104, 7, 0, 223, 175, 255, 141, 2, 0, 13 | 6, 4, 0, 223, 175, 255, 141, 2, 1, 17, 72, 101, 108, 108, 111, 32, 87, 111, 14 | 114, 108, 100, 33, 32, 240, 159, 140, 142, 0, 15 | ]; 16 | 17 | const CollaborativeEditor = (props: { 18 | slug: string; 19 | provider: HocuspocusProvider; 20 | }) => { 21 | /** 22 | * if you want to load initial content to the editor, the safest way to do so is by applying an initial Yjs update. 23 | * Yjs updates can safely be applied multiple times, while using `setContent` or similar Tiptap commands may result in 24 | * duplicate content in the Tiptap editor. 25 | * 26 | * The easiest way to generate the Yjs update (`initialContent` above) is to do something like 27 | * 28 | * ``` 29 | * console.log(Y.encodeStateAsUpdate(provider.props.document).toString()) 30 | * ``` 31 | * 32 | * after you have filled the editor with the desired content. 33 | */ 34 | Y.applyUpdate(props.provider.document, Uint8Array.from(initialContent)); 35 | 36 | const editor = useEditor( 37 | { 38 | extensions: [ 39 | // make sure to turn off the undo-redo extension when using collaboration 40 | StarterKit.configure({ 41 | undoRedo: false, 42 | }), 43 | Collaboration.configure({ 44 | document: props.provider.document, 45 | }), 46 | CollaborationCaret.configure({ 47 | provider: props.provider, 48 | }), 49 | ], 50 | // immediatelyRender needs to be `false` when using SSR 51 | immediatelyRender: false, 52 | editorProps: { 53 | attributes: { 54 | class: 55 | "prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none border border-gray p-3", 56 | }, 57 | }, 58 | }, 59 | [props.provider.document], 60 | ); 61 | 62 | return ; 63 | }; 64 | 65 | export default CollaborativeEditor; 66 | -------------------------------------------------------------------------------- /playground/frontend/app/articles/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import ArticleEditor from "@/app/articles/[slug]/ArticleEditor"; 2 | 3 | export default async function Home(props: { 4 | params: Promise<{ slug: string }>; 5 | }) { 6 | const params = await props.params; 7 | const slug = params.slug; 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /playground/frontend/app/articles/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SocketContext } from "@/app/SocketContext"; 4 | import { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; 5 | // import { 6 | // TiptapCollabProvider, 7 | // TiptapCollabProviderWebsocket, 8 | // } from "@tiptap-cloud/provider"; 9 | import { useEffect, useState } from "react"; 10 | 11 | export default function Layout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode; 15 | }>) { 16 | const [socket, setSocket] = useState( 17 | null, 18 | ); 19 | 20 | useEffect(() => { 21 | const newlyCreatedSocket = new HocuspocusProviderWebsocket({ 22 | url: "ws://localhost:1234", 23 | }); 24 | // const newlyCreatedSocket = new TiptapCollabProviderWebsocket({ 25 | // appId: "", 26 | // }); 27 | 28 | setSocket(newlyCreatedSocket); 29 | 30 | return () => { 31 | newlyCreatedSocket?.destroy(); 32 | }; 33 | }, []); 34 | 35 | if (socket) { 36 | return {children}; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /playground/frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ueberdosis/hocuspocus/1a73d14221a006f38d07063d7001ae8f1e14deaf/playground/frontend/app/favicon.ico -------------------------------------------------------------------------------- /playground/frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | 28 | /* Basic editor styles */ 29 | .tiptap { 30 | :first-child { 31 | margin-top: 0; 32 | } 33 | 34 | /* Placeholder (at the top) */ 35 | p.is-editor-empty:first-child::before { 36 | color: var(--gray-4); 37 | content: attr(data-placeholder); 38 | float: left; 39 | height: 0; 40 | pointer-events: none; 41 | } 42 | 43 | p { 44 | word-break: break-all; 45 | } 46 | 47 | /* Give a remote user a caret */ 48 | .collaboration-cursor__caret { 49 | border-left: 1px solid #0d0d0d; 50 | border-right: 1px solid #0d0d0d; 51 | margin-left: -1px; 52 | margin-right: -1px; 53 | pointer-events: none; 54 | position: relative; 55 | word-break: normal; 56 | } 57 | 58 | /* Render the username above the caret */ 59 | .collaboration-cursor__label { 60 | border-radius: 3px 3px 3px 0; 61 | color: #0d0d0d; 62 | font-size: 12px; 63 | font-style: normal; 64 | font-weight: 600; 65 | left: -1px; 66 | line-height: normal; 67 | padding: 0.1rem 0.3rem; 68 | position: absolute; 69 | top: -1.4em; 70 | user-select: none; 71 | white-space: nowrap; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /playground/frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import "./globals.css"; 3 | 4 | export default function RootLayout({ 5 | children, 6 | }: Readonly<{ 7 | children: React.ReactNode; 8 | }>) { 9 | return ( 10 | 11 | 12 |
13 |
14 |
    15 |
  • 16 | Homepage 17 |
  • 18 |
19 | 20 |

List of articles:

21 | 22 |
    23 |
  • 24 | Article 1 25 |
  • 26 |
  • 27 | Article 2 28 |
  • 29 |
  • 30 | Article 3 31 |
  • 32 |
  • 33 | Article 4 34 |
  • 35 |
36 |
37 | 38 |
{children}
39 |
40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /playground/frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return ( 3 |
4 |

Welcome to the Hocuspocus v3 playground!

5 |
6 | 7 |

8 | This demo is showing how to use a shared websocket connection to load 9 | multiple documents. Just open a second tab/window and experience 10 | collaborative editing :) 11 |

12 |

13 | The websocket is opened once you enter the articles/ routes and closed 14 | when you leave them. 15 |

16 |

You should open your browsers' console to see more information.

17 |

18 | Documents are fetched as needed (article 1 - 4) via the shared 19 | connection. 20 |

21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /playground/frontend/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | serverExternalPackages: ["yjs"], 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /playground/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hocuspocus/frontend-demo", 3 | "version": "3.1.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hocuspocus/provider": "^3.1.1", 13 | "@tiptap/extension-collaboration": "3.0.0-beta.5", 14 | "@tiptap/extension-collaboration-caret": "3.0.0-beta.5", 15 | "@tiptap/react": "3.0.0-beta.5", 16 | "@tiptap/starter-kit": "3.0.0-beta.5", 17 | "jwt-encode": "^1.0.1", 18 | "next": "15.2.4", 19 | "react": "^19.0.0", 20 | "react-dom": "^19.0.0" 21 | }, 22 | "overrides": { 23 | "@hocuspocus/provider": "$@hocuspocus/provider" 24 | }, 25 | "devDependencies": { 26 | "@tailwindcss/postcss": "^4", 27 | "@types/node": "^20", 28 | "@types/react": "^19", 29 | "@types/react-dom": "^19", 30 | "tailwindcss": "^4", 31 | "typescript": "^5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /playground/frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /playground/frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { fileURLToPath } from "url"; 3 | import batchPackages from "@lerna/batch-packages"; 4 | import { filterPackages } from "@lerna/filter-packages"; 5 | import { getPackages } from "@lerna/project"; 6 | import babel from "@rollup/plugin-babel"; 7 | import commonjs from "@rollup/plugin-commonjs"; 8 | import json from "@rollup/plugin-json"; 9 | import resolve from "@rollup/plugin-node-resolve"; 10 | // import sourcemaps from 'rollup-plugin-sourcemaps' 11 | import typescript from "@rollup/plugin-typescript"; 12 | import minimist from "minimist"; 13 | // import sizes from '@atomico/rollup-plugin-sizes' 14 | import autoExternal from "rollup-plugin-auto-external"; 15 | // import importAssertions from 'rollup-plugin-import-assertions' 16 | 17 | const __filename = fileURLToPath(import.meta.url); 18 | const __dirname = path.dirname(__filename); 19 | 20 | async function getSortedPackages(scope, ignore) { 21 | const packages = await getPackages(__dirname); 22 | const filtered = filterPackages(packages, scope, ignore, false); 23 | 24 | return batchPackages(filtered) 25 | .filter( 26 | (item) => !["@hocuspocus/docs", "@hocuspocus/demo"].includes(item.name), 27 | ) 28 | .reduce((arr, batch) => arr.concat(batch), []); 29 | } 30 | 31 | async function build(commandLineArgs) { 32 | const config = []; 33 | 34 | // Support --scope and --ignore globs if passed in via commandline 35 | const { scope, ignore, ci } = minimist(process.argv.slice(2)); 36 | const packages = await getSortedPackages(scope, ignore); 37 | 38 | // prevent rollup warning 39 | delete commandLineArgs.ci; 40 | delete commandLineArgs.scope; 41 | delete commandLineArgs.ignore; 42 | 43 | packages.forEach((pkg) => { 44 | const basePath = path.relative(__dirname, pkg.location); 45 | const input = path.join(basePath, "src/index.ts"); 46 | const { name, exports } = pkg.toJSON(); 47 | 48 | if (!exports) { 49 | return; 50 | } 51 | 52 | const basePlugins = [ 53 | // sourcemaps(), 54 | resolve(), 55 | commonjs(), 56 | babel({ 57 | babelHelpers: "bundled", 58 | exclude: "node_modules/**", 59 | }), 60 | // sizes(), 61 | json(), 62 | // importAssertions(), 63 | ]; 64 | 65 | config.push({ 66 | // perf: true, 67 | input, 68 | output: [ 69 | { 70 | name, 71 | file: path.join(basePath, exports.default.require), 72 | format: "cjs", 73 | sourcemap: true, 74 | exports: "auto", 75 | }, 76 | { 77 | name, 78 | file: path.join(basePath, exports.default.import), 79 | format: "es", 80 | sourcemap: true, 81 | }, 82 | ], 83 | plugins: [ 84 | autoExternal({ 85 | packagePath: path.join(basePath, "package.json"), 86 | }), 87 | ...basePlugins, 88 | typescript({ 89 | compilerOptions: { 90 | declaration: true, 91 | declarationDir: path.join(basePath, "dist"), 92 | paths: { 93 | "@hocuspocus/*": ["packages/*/src"], 94 | }, 95 | }, 96 | include: [], 97 | }), 98 | ], 99 | }); 100 | }); 101 | 102 | return config; 103 | } 104 | 105 | export default build; 106 | -------------------------------------------------------------------------------- /tests/extension-database/fetch.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import { Database } from '@hocuspocus/extension-database' 4 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 5 | 6 | test('fetch has the document name', async t => { 7 | await new Promise(async resolve => { 8 | const server = await newHocuspocus({ 9 | extensions: [ 10 | new Database({ 11 | async fetch({ documentName }) { 12 | t.is(documentName, 'my-unique-document-name') 13 | 14 | resolve('done') 15 | 16 | return null 17 | }, 18 | }), 19 | ], 20 | }) 21 | 22 | newHocuspocusProvider(server, { 23 | name: 'my-unique-document-name', 24 | }) 25 | }) 26 | }) 27 | 28 | test('passes context from onAuthenticate to fetch', async t => { 29 | await new Promise(async resolve => { 30 | const server = await newHocuspocus({ 31 | extensions: [ 32 | new Database({ 33 | async fetch({ context }) { 34 | t.deepEqual(context, { 35 | user: 123, 36 | }) 37 | 38 | resolve('done') 39 | 40 | return null 41 | }, 42 | }), 43 | ], 44 | async onAuthenticate() { 45 | return { 46 | user: 123, 47 | } 48 | }, 49 | }) 50 | 51 | newHocuspocusProvider(server, { 52 | token: 'SUPER-SECRET-TOKEN', 53 | name: 'my-unique-document-name', 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /tests/extension-logger/onListen.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | import { Logger } from '@hocuspocus/extension-logger' 4 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 5 | 6 | const fakeLogger = (message: any) => { 7 | } 8 | 9 | test('logs something', async t => { 10 | await new Promise(async resolve => { 11 | const spy = sinon.spy(fakeLogger) 12 | 13 | const server = await newHocuspocus({ 14 | extensions: [ 15 | new Logger({ 16 | log: spy, 17 | }), 18 | ], 19 | }) 20 | 21 | newHocuspocusProvider(server, { 22 | onConnect() { 23 | t.true(spy.callCount > 1, 'Expected the Logger to log something, but didn’t receive anything.') 24 | t.true(spy.callCount === 3, `Expected it to log 11 times, but actually logged ${spy.callCount} times`) 25 | 26 | resolve('done') 27 | }, 28 | }) 29 | }) 30 | }) 31 | 32 | test('uses the global instance name', async t => { 33 | await new Promise(async resolve => { 34 | const spy = sinon.spy(fakeLogger) 35 | 36 | const hocuspocus = await newHocuspocus({ 37 | name: 'FOOBAR123', 38 | async onDestroy() { 39 | t.is(spy.args[spy.args.length - 1][0].includes('FOOBAR123'), true, 'Expected the Logger to use the configured instance name.') 40 | 41 | resolve('done') 42 | }, 43 | extensions: [ 44 | new Logger({ 45 | log: spy, 46 | }), 47 | ], 48 | }) 49 | 50 | await hocuspocus.server!.destroy() 51 | }) 52 | 53 | }) 54 | 55 | test('doesn’t log anything if all messages are disabled', async t => { 56 | await new Promise(async resolve => { 57 | const spy = sinon.spy(fakeLogger) 58 | 59 | const hocuspocus = await newHocuspocus({ 60 | async onDestroy() { 61 | t.is(spy.callCount, 0, 'Expected the Logger to not log anything.') 62 | 63 | resolve('done') 64 | }, 65 | extensions: [ 66 | new Logger({ 67 | log: spy, 68 | // TODO: Those hooks aren’t triggered anyway. 69 | // onLoadDocument: false, 70 | // onChange: false, 71 | // onConnect: false, 72 | // onDisconnect: false, 73 | // onUpgrade: false, 74 | // onRequest: false, 75 | // @ts-ignore 76 | onListen: false, 77 | onDestroy: false, 78 | onConfigure: false, 79 | }), 80 | ], 81 | }) 82 | 83 | await hocuspocus.server!.destroy() 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/extension-redis/onAwarenessChange.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { Redis } from '@hocuspocus/extension-redis' 3 | import type { onAwarenessChangeParameters } from '@hocuspocus/provider' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import { newHocuspocus, newHocuspocusProvider, redisConnectionSettings } from '../utils/index.ts' 6 | 7 | test('syncs existing awareness state', async t => { 8 | await new Promise(async resolve => { 9 | const server = await newHocuspocus({ 10 | extensions: [ 11 | new Redis({ 12 | ...redisConnectionSettings, 13 | identifier: `server${uuidv4()}`, 14 | }), 15 | ], 16 | }) 17 | 18 | const anotherServer = await newHocuspocus({ 19 | extensions: [ 20 | new Redis({ 21 | ...redisConnectionSettings, 22 | identifier: `anotherServer${uuidv4()}`, 23 | }), 24 | ], 25 | }) 26 | 27 | const provider = newHocuspocusProvider(server, { 28 | onSynced() { 29 | // Once we’re set up, change the local Awareness state. 30 | // The updated state then needs to go through Redis: 31 | // provider -> server -> Redis -> anotherServer -> anotherProvider 32 | provider.setAwarenessField('name', 'first') 33 | 34 | // Time to initialize a second provider, and connect to `anotherServer` 35 | // to check whether existing Awareness states are synced through Redis. 36 | newHocuspocusProvider(anotherServer, { 37 | onAwarenessChange({ states }: onAwarenessChangeParameters) { 38 | t.is(states.length, 2) 39 | 40 | const state = states.find(state => state.clientId === provider.document.clientID) 41 | t.is(state?.name, 'first') 42 | 43 | resolve('done') 44 | }, 45 | }) 46 | }, 47 | }) 48 | }) 49 | }) 50 | 51 | test('syncs awareness between servers and clients', async t => { 52 | await new Promise(async resolve => { 53 | const server = await newHocuspocus({ 54 | extensions: [ 55 | new Redis({ 56 | ...redisConnectionSettings, 57 | identifier: `server${uuidv4()}`, 58 | }), 59 | ], 60 | }) 61 | 62 | const anotherServer = await newHocuspocus({ 63 | extensions: [ 64 | new Redis({ 65 | ...redisConnectionSettings, 66 | identifier: `anotherServer${uuidv4()}`, 67 | }), 68 | ], 69 | }) 70 | 71 | const provider = newHocuspocusProvider(anotherServer, { 72 | name: 'another-document', 73 | onSynced() { 74 | // once we're setup change awareness on provider, to get to client it will 75 | // need to pass through the pubsub extension: 76 | // provider -> anotherServer -> pubsub -> server -> client 77 | provider.setAwarenessField('name', 'second') 78 | }, 79 | }) 80 | 81 | newHocuspocusProvider(server, { 82 | name: 'another-document', 83 | onAwarenessChange: ({ states }: onAwarenessChangeParameters) => { 84 | t.is(states.length, 2) 85 | 86 | const state = states.find(state => state.clientId === provider.document.clientID) 87 | t.is(state?.name, 'second') 88 | 89 | resolve('done') 90 | }, 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tests/extension-redis/onChange.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { Redis } from '@hocuspocus/extension-redis' 3 | import { v4 as uuidv4 } from 'uuid' 4 | import { newHocuspocus, newHocuspocusProvider, redisConnectionSettings } from '../utils/index.ts' 5 | 6 | test('syncs updates between servers and clients', async t => { 7 | await new Promise(async resolve => { 8 | const server = await newHocuspocus({ 9 | extensions: [ 10 | new Redis({ 11 | ...redisConnectionSettings, 12 | identifier: `server${uuidv4()}`, 13 | }), 14 | ], 15 | }) 16 | 17 | const anotherServer = await newHocuspocus({ 18 | extensions: [ 19 | new Redis({ 20 | ...redisConnectionSettings, 21 | identifier: `anotherServer${uuidv4()}`, 22 | }), 23 | ], 24 | }) 25 | 26 | // Once we’re setup make an edit on anotherProvider. To get to the provider it will need 27 | // to pass through Redis: 28 | // provider -> server -> Redis -> anotherServer -> anotherProvider 29 | const provider = newHocuspocusProvider(server, { 30 | onSynced() { 31 | provider.document.getArray('foo').insert(0, ['bar']) 32 | }, 33 | }) 34 | 35 | // Once the initial data is synced, wait for an additional update to check 36 | // if both documents have the same content. 37 | const anotherProvider = newHocuspocusProvider(anotherServer, { 38 | onSynced() { 39 | provider.on('message', () => { 40 | setTimeout(() => { 41 | t.is( 42 | provider.document.getArray('foo').get(0), 43 | anotherProvider.document.getArray('foo').get(0), 44 | ) 45 | 46 | resolve('done') 47 | }, 200) 48 | }) 49 | }, 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /tests/extension-throttle/banning.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { onConnectPayload } from '@hocuspocus/server' 3 | import * as MockDate from 'mockdate' 4 | import { Throttle } from '@hocuspocus/extension-throttle' 5 | 6 | const getOnConnectPayload = (ip: string) => { 7 | return { 8 | request: { 9 | headers: { 10 | 'x-real-ip': ip, 11 | }, 12 | }, 13 | } as unknown as onConnectPayload 14 | } 15 | const generateRequests = async (instance: Throttle, ip: string, numberOfRequests: number) => { 16 | for (let i = 0; i < numberOfRequests; i += 1) { 17 | // eslint-disable-next-line no-await-in-loop 18 | await instance.onConnect(getOnConnectPayload(ip)) 19 | } 20 | } 21 | 22 | test('throttle extension bans properly', async t => { 23 | const throttle = new Throttle({ banTime: 5, throttle: 15 }) 24 | const ip = '127.0.0.1' 25 | 26 | t.false(throttle.isBanned(ip)) 27 | 28 | await generateRequests(throttle, ip, 15) 29 | 30 | try { 31 | await throttle.onConnect(getOnConnectPayload(ip)) 32 | t.fail() 33 | } catch (e) { 34 | t.true(throttle.isBanned(ip)) 35 | } 36 | 37 | }) 38 | 39 | test('throttle extension unbans properly', async t => { 40 | const throttle = new Throttle({ banTime: 5, throttle: 15 }) 41 | const ip = '127.0.0.1' 42 | 43 | t.false(throttle.isBanned(ip)) 44 | 45 | await generateRequests(throttle, ip, 15) 46 | 47 | try { 48 | await throttle.onConnect(getOnConnectPayload(ip)) 49 | t.fail() 50 | } catch (e) { 51 | t.true(throttle.isBanned(ip)) 52 | } 53 | 54 | MockDate.set(Date.now() + 1000 * (throttle.configuration.banTime * 60)) 55 | 56 | await throttle.onConnect(getOnConnectPayload(ip)) 57 | t.false(throttle.isBanned(ip)) 58 | 59 | MockDate.reset() 60 | }) 61 | 62 | test.serial('map cleanup works for connectionsByIp', async t => { 63 | const throttle = new Throttle({ consideredSeconds: 60 }) 64 | const ip = '127.0.0.1' 65 | 66 | await generateRequests(throttle, ip, 10) 67 | 68 | t.is(throttle.connectionsByIp.get(ip)!.length, 10) 69 | 70 | MockDate.set(Date.now() + 1000 * throttle.configuration.consideredSeconds) 71 | 72 | await throttle.clearMaps() 73 | 74 | t.false(throttle.connectionsByIp.has(ip)) 75 | 76 | MockDate.reset() 77 | }) 78 | 79 | test.serial('map cleanup works for bannedIps', async t => { 80 | const throttle = new Throttle({ consideredSeconds: 60, throttle: 15 }) 81 | const ip = '127.0.0.1' 82 | 83 | await generateRequests(throttle, ip, 15) 84 | 85 | try { 86 | await throttle.onConnect(getOnConnectPayload(ip)) 87 | // eslint-disable-next-line no-empty 88 | } catch (e) {} 89 | 90 | t.true(throttle.bannedIps.has(ip)) 91 | 92 | MockDate.set(Date.now() + 1000 * throttle.configuration.banTime * 60) 93 | 94 | await throttle.clearMaps() 95 | 96 | t.false(throttle.bannedIps.has(ip)) 97 | 98 | MockDate.reset() 99 | }) 100 | -------------------------------------------------------------------------------- /tests/extension-throttle/configuration.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { Throttle } from '@hocuspocus/extension-throttle' 3 | 4 | test('throttle has the default configuration', async t => { 5 | t.is(new Throttle().configuration.throttle, 15) 6 | }) 7 | 8 | test('banTime has the default configuration', async t => { 9 | t.is(new Throttle().configuration.banTime, 5) 10 | }) 11 | 12 | test('throttle has a custom value', async t => { 13 | t.is(new Throttle({ throttle: 100 }).configuration.throttle, 100) 14 | }) 15 | 16 | test('banTime has a custom value', async t => { 17 | t.is(new Throttle({ banTime: 100 }).configuration.banTime, 100) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tests", 3 | "version": "3.1.1", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@hocuspocus/extension-logger": "^3.1.1", 8 | "@hocuspocus/extension-redis": "^3.1.1", 9 | "@hocuspocus/extension-throttle": "^3.1.1", 10 | "@hocuspocus/provider": "^3.1.1", 11 | "@hocuspocus/server": "^3.1.1", 12 | "@hocuspocus/transformer": "^3.1.1", 13 | "redis": "^4.0.4", 14 | "sinon": "^12.0.1", 15 | "ws": "^8.5.0", 16 | "yjs": "^13.6.4" 17 | }, 18 | "devDependencies": { 19 | "@types/redis": "^4.0.11", 20 | "@types/sinon": "^10.0.11", 21 | "lib0": "^0.2.47", 22 | "mockdate": "^3.0.5", 23 | "uuid": "^8.3.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/provider/observe.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as Y from 'yjs' 3 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 4 | import { retryableAssertion } from '../utils/retryableAssertion.ts' 5 | 6 | test('observe is called just once', async t => { 7 | let count = 0 8 | 9 | const server = await newHocuspocus() 10 | const provider = newHocuspocusProvider(server) 11 | 12 | const type = provider.document.get( 13 | 'xmlText', 14 | Y.XmlText, 15 | ) as unknown as Y.XmlText 16 | 17 | // Count how often observe is called … 18 | type.observe((events, transaction) => { 19 | count += 1 20 | }) 21 | 22 | // Insert something … 23 | type.insert(1, 'a') 24 | 25 | await retryableAssertion(t, tt => { 26 | tt.is(count, 1) 27 | }) 28 | 29 | }) 30 | 31 | test('observe is called for every single change', async t => { 32 | let count = 0 33 | 34 | const server = await newHocuspocus() 35 | const provider = newHocuspocusProvider(server) 36 | 37 | const type = provider.document.get( 38 | 'xmlText', 39 | Y.XmlText, 40 | ) as unknown as Y.XmlText 41 | 42 | // Count how often observe is called … 43 | type.observe((events, transaction) => { 44 | count += 1 45 | }) 46 | 47 | // Insert something … 48 | type.insert(1, 'a') 49 | type.insert(2, 'b') 50 | type.insert(3, 'c') 51 | 52 | await retryableAssertion(t, tt => { 53 | tt.is(count, 3) 54 | }) 55 | }) 56 | 57 | test('observe is called once for a single transaction', async t => { 58 | let count = 0 59 | 60 | const server = await newHocuspocus() 61 | const provider = newHocuspocusProvider(server) 62 | 63 | const type = provider.document.get( 64 | 'xmlText', 65 | Y.XmlText, 66 | ) as unknown as Y.XmlText 67 | 68 | // Count how often observe is called … 69 | type.observe((events, transaction) => { 70 | count += 1 71 | }) 72 | 73 | // Insert something … 74 | Y.transact(provider.document, () => { 75 | type.insert(1, 'a') 76 | type.insert(2, 'b') 77 | type.insert(3, 'c') 78 | }) 79 | 80 | await retryableAssertion(t, tt => { 81 | tt.is(count, 1) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /tests/provider/observeDeep.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as Y from 'yjs' 3 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 4 | import { retryableAssertion } from '../utils/retryableAssertion.ts' 5 | 6 | test('observeDeep is called just once', async t => { 7 | let count = 0 8 | 9 | const server = await newHocuspocus() 10 | const provider = newHocuspocusProvider(server) 11 | 12 | const type = provider.document.get( 13 | 'xmlText', 14 | Y.XmlText, 15 | ) as unknown as Y.XmlText 16 | 17 | // Count how often observeDeep is called … 18 | type.observeDeep((events, transaction) => { 19 | count += 1 20 | }) 21 | 22 | // Insert something … 23 | type.insert(1, 'a') 24 | 25 | await retryableAssertion(t, tt => { 26 | tt.is(count, 1) 27 | }) 28 | }) 29 | 30 | test('observeDeep is called for every single change', async t => { 31 | let count = 0 32 | 33 | const server = await newHocuspocus() 34 | const provider = newHocuspocusProvider(server) 35 | 36 | const type = provider.document.get( 37 | 'xmlText', 38 | Y.XmlText, 39 | ) as unknown as Y.XmlText 40 | 41 | // Count how often observeDeep is called … 42 | type.observeDeep((events, transaction) => { 43 | count += 1 44 | }) 45 | 46 | // Insert something … 47 | type.insert(1, 'a') 48 | type.insert(2, 'b') 49 | type.insert(3, 'c') 50 | 51 | await retryableAssertion(t, tt => { 52 | tt.is(count, 3) 53 | }) 54 | }) 55 | 56 | test('observeDeep is called once for a single transaction', async t => { 57 | let count = 0 58 | 59 | const server = await newHocuspocus() 60 | const provider = newHocuspocusProvider(server) 61 | 62 | const type = provider.document.get( 63 | 'xmlText', 64 | Y.XmlText, 65 | ) as unknown as Y.XmlText 66 | 67 | // Count how often observeDeep is called … 68 | type.observeDeep((events, transaction) => { 69 | count += 1 70 | }) 71 | 72 | // Insert something … 73 | Y.transact(provider.document, () => { 74 | type.insert(1, 'a') 75 | type.insert(2, 'b') 76 | type.insert(3, 'c') 77 | }) 78 | 79 | await retryableAssertion(t, tt => { 80 | tt.is(count, 1) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /tests/provider/onAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onAuthenticated callback', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus({ 7 | async onAuthenticate({ token }) { 8 | if (token !== 'SUPER-SECRET-TOKEN') { 9 | throw new Error() 10 | } 11 | }, 12 | }) 13 | 14 | const provider = newHocuspocusProvider(server, { 15 | token: 'SUPER-SECRET-TOKEN', 16 | onAuthenticated() { 17 | t.is(provider.isAuthenticated, true) 18 | t.is(provider.authorizedScope, 'read-write') 19 | t.pass() 20 | resolve('done') 21 | }, 22 | }) 23 | }) 24 | }) 25 | 26 | test('executes the onAuthenticated callback when token is provided as a function that returns a promise', async t => { 27 | await new Promise(async resolve => { 28 | const server = await newHocuspocus({ 29 | async onAuthenticate({ token }) { 30 | if (token !== 'SUPER-SECRET-TOKEN') { 31 | throw new Error() 32 | } 33 | }, 34 | }) 35 | 36 | const provider = newHocuspocusProvider(server, { 37 | token: async () => Promise.resolve('SUPER-SECRET-TOKEN'), 38 | onAuthenticated() { 39 | t.is(provider.isAuthenticated, true) 40 | t.is(provider.authorizedScope, 'read-write') 41 | t.pass() 42 | resolve('done') 43 | }, 44 | }) 45 | }) 46 | }) 47 | 48 | test('executes the onAuthenticated callback when token is provided as a function that returns a string', async t => { 49 | await new Promise(async resolve => { 50 | const server = await newHocuspocus({ 51 | async onAuthenticate({ token }) { 52 | if (token !== 'SUPER-SECRET-TOKEN') { 53 | throw new Error() 54 | } 55 | }, 56 | }) 57 | 58 | const provider = newHocuspocusProvider(server, { 59 | token: () => 'SUPER-SECRET-TOKEN', 60 | onAuthenticated() { 61 | t.is(provider.isAuthenticated, true) 62 | t.is(provider.authorizedScope, 'read-write') 63 | t.pass() 64 | resolve('done') 65 | }, 66 | }) 67 | }) 68 | }) 69 | 70 | test('sets correct scope for readonly', async t => { 71 | await new Promise(async resolve => { 72 | const server = await newHocuspocus({ 73 | async onAuthenticate({ token, connectionConfig }) { 74 | if (token !== 'SUPER-SECRET-TOKEN') { 75 | throw new Error() 76 | } 77 | connectionConfig.readOnly = true 78 | }, 79 | }) 80 | 81 | const provider = newHocuspocusProvider(server, { 82 | token: 'SUPER-SECRET-TOKEN', 83 | onAuthenticated() { 84 | t.is(provider.isAuthenticated, true) 85 | t.is(provider.authorizedScope, 'readonly') 86 | t.pass() 87 | resolve('done') 88 | }, 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /tests/provider/onAuthenticationFailed.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onAuthenticationFailed callback', async t => { 5 | await new Promise(async resolve => { 6 | newHocuspocus({ 7 | async onAuthenticate({ token }) { 8 | throw new Error() 9 | }, 10 | }).then(server => { 11 | newHocuspocusProvider(server, { 12 | token: 'SUPER-SECRET-TOKEN', 13 | onAuthenticationFailed() { 14 | t.pass() 15 | resolve('done') 16 | }, 17 | }) 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /tests/provider/onClose.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('onClose callback is executed', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus() 7 | 8 | const provider = newHocuspocusProvider(server, { 9 | onConnect() { 10 | provider.configuration.websocketProvider.disconnect() 11 | }, 12 | onClose() { 13 | t.pass() 14 | resolve('done') 15 | }, 16 | }) 17 | }) 18 | }) 19 | 20 | test("on('close') callback is executed", async t => { 21 | await new Promise(async resolve => { 22 | const server = await newHocuspocus() 23 | 24 | const provider = newHocuspocusProvider(server) 25 | 26 | provider.on('connect', () => { 27 | provider.configuration.websocketProvider.disconnect() 28 | }) 29 | 30 | provider.on('close', () => { 31 | t.pass() 32 | resolve('done') 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /tests/provider/onConnect.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onConnect callback', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus() 7 | 8 | newHocuspocusProvider(server, { 9 | onConnect() { 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | }) 15 | }) 16 | 17 | test("executes the on('connect') callback", async t => { 18 | await new Promise(async resolve => { 19 | const server = await newHocuspocus() 20 | 21 | const provider = newHocuspocusProvider(server) 22 | 23 | provider.on('connect', () => { 24 | t.pass() 25 | resolve('done') 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/provider/onDisconnect.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('onDisconnect callback is executed', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus() 7 | 8 | const provider = newHocuspocusProvider(server, { 9 | onConnect() { 10 | provider.configuration.websocketProvider.disconnect() 11 | provider.disconnect() 12 | }, 13 | onDisconnect() { 14 | t.pass() 15 | resolve('done') 16 | }, 17 | }) 18 | }) 19 | }) 20 | 21 | test("on('disconnect') callback is executed", async t => { 22 | await new Promise(async resolve => { 23 | const server = await newHocuspocus() 24 | 25 | const provider = newHocuspocusProvider(server) 26 | 27 | provider.on('connect', () => { 28 | provider.configuration.websocketProvider.disconnect() 29 | provider.disconnect() 30 | }) 31 | provider.on('disconnect', () => { 32 | t.pass() 33 | resolve('done') 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /tests/provider/onMessage.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onMessage callback', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus({ }) 7 | 8 | newHocuspocusProvider(server, { 9 | onMessage() { 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | }) 15 | }) 16 | 17 | test("executes the on('message') callback", async t => { 18 | await new Promise(async resolve => { 19 | const server = await newHocuspocus() 20 | 21 | const provider = newHocuspocusProvider(server) 22 | 23 | provider.on('message', () => { 24 | t.pass() 25 | resolve('done') 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/provider/onOpen.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('onOpen callback is executed', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus() 7 | 8 | newHocuspocusProvider(server, { 9 | onOpen() { 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | }) 15 | }) 16 | 17 | test("on('open') callback is executed", async t => { 18 | await new Promise(async resolve => { 19 | const server = await newHocuspocus() 20 | 21 | const provider = newHocuspocusProvider(server) 22 | 23 | provider.on('open', () => { 24 | t.pass() 25 | resolve('done') 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/provider/onStateless.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onStateless callback', async t => { 5 | const payloadToSend = 'STATELESS-MESSAGE' 6 | await new Promise(async resolve => { 7 | newHocuspocus({ 8 | async onStateless({ payload }) { 9 | t.is(payload, payloadToSend) 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }).then(server => { 14 | const provider = newHocuspocusProvider(server, { 15 | onSynced: () => { 16 | provider.sendStateless(payloadToSend) 17 | }, 18 | }) 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/provider/onSynced.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts' 3 | 4 | test('onSynced callback is executed', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus() 7 | 8 | newHocuspocusProvider(server, { 9 | onSynced() { 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | }) 15 | }) 16 | 17 | test("on('synced') callback is executed", async t => { 18 | await new Promise(async resolve => { 19 | const server = await newHocuspocus() 20 | 21 | const provider = newHocuspocusProvider(server) 22 | 23 | provider.on('synced', () => { 24 | t.pass() 25 | resolve('done') 26 | }) 27 | }) 28 | }) 29 | 30 | test('onSynced callback is executed, even when the onConnect takes longer', async t => { 31 | await new Promise(async resolve => { 32 | const server = await newHocuspocus({ 33 | async onConnect(data) { 34 | await sleep(100) 35 | }, 36 | }) 37 | 38 | newHocuspocusProvider(server, { 39 | onSynced() { 40 | t.pass() 41 | resolve('done') 42 | }, 43 | }) 44 | }) 45 | }) 46 | 47 | test('onSynced callback is executed when the document is actually synced', async t => { 48 | await new Promise(async resolve => { 49 | const server = await newHocuspocus({ 50 | async onLoadDocument({ document }) { 51 | document.getArray('foo').insert(0, ['bar']) 52 | 53 | return document 54 | }, 55 | }) 56 | 57 | const provider = newHocuspocusProvider(server, { 58 | onSynced() { 59 | const value = provider.document.getArray('foo').get(0) 60 | t.is(value, 'bar') 61 | 62 | resolve('done') 63 | }, 64 | }) 65 | }) 66 | }) 67 | 68 | test('send all messages according to the protocol', async t => { 69 | await new Promise(async resolve => { 70 | const server = await newHocuspocus({ 71 | async onLoadDocument({ document }) { 72 | document.getArray('foo').insert(0, ['bar']) 73 | 74 | return document 75 | }, 76 | }) 77 | 78 | const provider = newHocuspocusProvider(server, { 79 | async onSynced() { 80 | t.deepEqual(provider.document.getArray('foo').get(0), 'bar') 81 | 82 | resolve('done') 83 | }, 84 | }) 85 | }) 86 | }) 87 | 88 | test('onSynced callback is executed when the document is actually synced, even if it takes longer', async t => { 89 | await new Promise(async resolve => { 90 | const server = await newHocuspocus({ 91 | async onLoadDocument({ document }) { 92 | await sleep(100) 93 | 94 | document.getArray('foo').insert(0, ['bar']) 95 | 96 | return document 97 | }, 98 | }) 99 | 100 | const provider = newHocuspocusProvider(server, { 101 | onSynced() { 102 | const value = provider.document.getArray('foo').get(0) 103 | t.is(value, 'bar') 104 | 105 | resolve('done') 106 | }, 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /tests/providerwebsocket/configuration.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProviderWebsocket } from '../utils/index.ts' 3 | 4 | test('has default configuration (maxDelay = 30000)', async t => { 5 | const server = await newHocuspocus() 6 | const client = newHocuspocusProviderWebsocket(server) 7 | 8 | t.is(client.configuration.maxDelay, 30000) 9 | }) 10 | 11 | test('overwrites the default configuration', async t => { 12 | const server = await newHocuspocus() 13 | const client = newHocuspocusProviderWebsocket(server, { 14 | maxDelay: 10000, 15 | }) 16 | 17 | t.is(client.configuration.maxDelay, 10000) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/server/address.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus } from '../utils/index.ts' 3 | 4 | test('returns a dynamic HTTP/WebSocket address with the correct port', async t => { 5 | const hocuspocus = await newHocuspocus({ 6 | port: 4010, 7 | }) 8 | 9 | t.is(hocuspocus.server!.address.port, 4010) 10 | t.is(hocuspocus.server!.httpURL, 'http://0.0.0.0:4010') 11 | t.is(hocuspocus.server!.webSocketURL, 'ws://0.0.0.0:4010') 12 | 13 | t.pass() 14 | }) 15 | -------------------------------------------------------------------------------- /tests/server/afterLoadDocument.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { HocuspocusProvider } from '@hocuspocus/provider' 3 | 4 | import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts' 5 | 6 | test('executes the afterLoadDocument callback', async t => { 7 | await new Promise(async resolve => { 8 | const server = await newHocuspocus({ 9 | async afterLoadDocument() { 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | 15 | newHocuspocusProvider(server, {}) 16 | }) 17 | }) 18 | 19 | test('executes the afterLoadDocument callback in an extension', async t => { 20 | await new Promise(async resolve => { 21 | let provider: HocuspocusProvider 22 | 23 | class CustomExtension { 24 | async afterLoadDocument() { 25 | t.pass() 26 | resolve('done') 27 | } 28 | } 29 | 30 | const server = await newHocuspocus({ 31 | extensions: [new CustomExtension()], 32 | }) 33 | 34 | newHocuspocusProvider(server) 35 | }) 36 | }) 37 | 38 | test('does not execute the afterLoadDocument callback when document fails to load', async t => { 39 | await new Promise(async resolve => { 40 | const server = await newHocuspocus() 41 | 42 | class CustomExtension { 43 | async onLoadDocument() { 44 | throw new Error('oops!') 45 | } 46 | 47 | async afterLoadDocument() { 48 | t.fail('this should not be executed') 49 | resolve('done') 50 | } 51 | } 52 | 53 | server.configure({ 54 | extensions: [ 55 | new CustomExtension(), 56 | ], 57 | }) 58 | 59 | newHocuspocusProvider(server) 60 | 61 | await sleep(300) 62 | t.pass() 63 | resolve('') 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /tests/server/afterStoreDocument.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('calls the afterStoreDocument hook', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus({ 7 | async afterStoreDocument() { 8 | t.pass() 9 | 10 | resolve('done') 11 | }, 12 | }) 13 | 14 | const provider = newHocuspocusProvider(server, { 15 | onSynced() { 16 | // Dummy change to trigger onStoreDocument 17 | provider.document.getArray('foo').push(['foo']) 18 | provider.configuration.websocketProvider.destroy() 19 | provider.destroy() 20 | }, 21 | }) 22 | }) 23 | }) 24 | 25 | test('executes afterStoreDocument callback from a custom extension', async t => { 26 | await new Promise(async resolve => { 27 | class CustomExtension { 28 | async afterStoreDocument() { 29 | t.pass() 30 | 31 | resolve('done') 32 | } 33 | } 34 | 35 | const server = await newHocuspocus({ 36 | extensions: [ 37 | new CustomExtension(), 38 | ], 39 | }) 40 | 41 | const provider = newHocuspocusProvider(server, { 42 | onSynced() { 43 | provider.document.getArray('foo').insert(0, ['bar']) 44 | }, 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /tests/server/afterUnloadDocument.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { HocuspocusProvider } from '@hocuspocus/provider' 3 | 4 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 5 | 6 | test('executes the afterUnloadDocument callback', async t => { 7 | await new Promise(async resolve => { 8 | const server = await newHocuspocus({ 9 | async afterUnloadDocument() { 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | 15 | const p = newHocuspocusProvider(server, { 16 | onSynced(data) { 17 | p.configuration.websocketProvider.disconnect() 18 | p.disconnect() 19 | }, 20 | }) 21 | }) 22 | }) 23 | 24 | test('executes the afterUnloadDocument callback when all clients disconnect after a document was loaded', async t => { 25 | await new Promise(async resolve => { 26 | // eslint-disable-next-line prefer-const 27 | let provider: HocuspocusProvider 28 | 29 | class CustomExtension { 30 | async afterLoadDocument() { 31 | provider.configuration.websocketProvider.disconnect() 32 | provider.disconnect() 33 | } 34 | 35 | async afterUnloadDocument() { 36 | t.pass() 37 | resolve('done') 38 | } 39 | } 40 | 41 | const server = await newHocuspocus({ 42 | extensions: [new CustomExtension()], 43 | }) 44 | 45 | provider = newHocuspocusProvider(server) 46 | }) 47 | }) 48 | 49 | test('executes the afterUnloadDocument callback when document fails to load', async t => { 50 | await new Promise(async resolve => { 51 | const server = await newHocuspocus() 52 | 53 | class CustomExtension { 54 | async onLoadDocument() { 55 | throw new Error('oops!') 56 | } 57 | 58 | async afterUnloadDocument() { 59 | t.pass() 60 | resolve('done') 61 | } 62 | } 63 | 64 | server.configure({ 65 | extensions: [ 66 | new CustomExtension(), 67 | ], 68 | }) 69 | 70 | newHocuspocusProvider(server) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/server/beforeBroadcastStateless.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('calls the beforeBroadcastStateless hook', async t => { 5 | await new Promise(async resolve => { 6 | const payloadToSend = 'STATELESS-MESSAGE' 7 | const server = await newHocuspocus({ 8 | async beforeBroadcastStateless({ payload }) { 9 | t.is(payload, payloadToSend) 10 | t.pass() 11 | resolve('done') 12 | }, 13 | }) 14 | 15 | newHocuspocusProvider(server, { 16 | onSynced() { 17 | server.documents.get('hocuspocus-test')?.broadcastStateless(payloadToSend) 18 | }, 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/server/beforeHandleMessage.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | import { retryableAssertion } from '../utils/retryableAssertion.ts' 4 | 5 | test('beforeHandleMessage gets called in proper order', async t => { 6 | await new Promise(async resolve => { 7 | const mockContext = { 8 | user: 123, 9 | } 10 | 11 | const expectedValuesByCallNumber = [ 12 | undefined, // syncstep1 13 | undefined, // syncstep2 14 | 'foo', // sync finished, value should be there now 15 | ] 16 | let callNumber = 0 17 | 18 | const server = await newHocuspocus({ 19 | async onConnect() { 20 | return mockContext 21 | }, 22 | async beforeHandleMessage({ document, context }) { 23 | t.deepEqual(context, mockContext) 24 | 25 | const value = document.getArray('foo').get(0) 26 | 27 | t.is(value, expectedValuesByCallNumber[callNumber]) 28 | callNumber += 1 29 | 30 | if (callNumber === expectedValuesByCallNumber.length - 1) { 31 | resolve('done') 32 | } 33 | }, 34 | async onChange({ context, document }) { 35 | t.deepEqual(context, mockContext) 36 | 37 | const value = document.getArray('foo').get(0) 38 | 39 | t.is(value, expectedValuesByCallNumber[2]) 40 | }, 41 | }) 42 | 43 | const provider = newHocuspocusProvider(server, { 44 | onSynced() { 45 | provider.document.getArray('foo').insert(0, ['bar']) 46 | }, 47 | }) 48 | }) 49 | }) 50 | 51 | test('beforeHandleMessage callback is called for every new client', async t => { 52 | let onConnectCount = 0 53 | let beforeHandleMessageCount = 0 54 | 55 | await new Promise(async resolve => { 56 | const server = await newHocuspocus({ 57 | async onConnect() { 58 | onConnectCount += 1 59 | }, 60 | async beforeHandleMessage() { 61 | beforeHandleMessageCount += 1 62 | }, 63 | }) 64 | 65 | newHocuspocusProvider(server, { 66 | onClose() { 67 | t.fail() 68 | }, 69 | }) 70 | newHocuspocusProvider(server, { 71 | onClose() { 72 | t.fail() 73 | }, 74 | }) 75 | 76 | resolve('done') 77 | }) 78 | 79 | await retryableAssertion(t, tt => { 80 | tt.is(onConnectCount, 2) 81 | tt.is(beforeHandleMessageCount, 6) // 2x awareness per conn, 2x sync per conn (step 1 + 2) 82 | }) 83 | 84 | }) 85 | 86 | test('an exception thrown in beforeHandleMessage closes the connection', async t => { 87 | await new Promise(async resolve => { 88 | const server = await newHocuspocus({ 89 | async beforeHandleMessage() { 90 | throw new Error() 91 | }, 92 | }) 93 | 94 | newHocuspocusProvider(server, { 95 | onClose() { 96 | t.pass() 97 | resolve('done') 98 | }, 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /tests/server/closeConnections.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { WebSocketStatus } from '@hocuspocus/provider' 3 | import { 4 | newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket, sleep, 5 | } from '../utils/index.ts' 6 | import { retryableAssertion } from '../utils/retryableAssertion.ts' 7 | 8 | // test('closes all connections', async t => { 9 | // const server = await newHocuspocus() 10 | // const socket = newHocuspocusProviderWebsocket(server) 11 | // const socket2 = newHocuspocusProviderWebsocket(server) 12 | 13 | // const provider = newHocuspocusProvider(server, { 14 | // name: 'hocuspocus-test', 15 | // onClose() { 16 | // // Make sure it doesn’t reconnect. 17 | // socket.disconnect() 18 | // }, 19 | // websocketProvider: socket, 20 | // }) 21 | 22 | // const anotherProvider = newHocuspocusProvider(server, { 23 | // name: 'hocuspocus-test-2', 24 | // onClose() { 25 | // // Make sure it doesn’t reconnect. 26 | // socket2.disconnect() 27 | // }, 28 | // websocketProvider: socket2, 29 | // }) 30 | 31 | // await sleep(100) 32 | 33 | // server.closeConnections() 34 | 35 | // t.is(server.documents.size, 1) 36 | // }) 37 | 38 | test('closes a specific connection when a documentName is passed', async t => { 39 | const server = await newHocuspocus() 40 | const socket = newHocuspocusProviderWebsocket(server) 41 | const socket2 = newHocuspocusProviderWebsocket(server) 42 | 43 | const provider = newHocuspocusProvider(server, { 44 | name: 'hocuspocus-test', 45 | onClose() { 46 | // Make sure it doesn’t reconnect. 47 | socket.disconnect() 48 | }, 49 | websocketProvider: socket, 50 | }) 51 | 52 | const anotherProvider = newHocuspocusProvider(server, { 53 | name: 'hocuspocus-test-2', 54 | websocketProvider: socket2, 55 | }) 56 | 57 | await sleep(100) 58 | 59 | server.closeConnections('hocuspocus-test') 60 | 61 | await retryableAssertion(t, tt => { 62 | tt.is(socket.status, WebSocketStatus.Disconnected) 63 | tt.is(socket2.status, WebSocketStatus.Connected) 64 | }) 65 | }) 66 | 67 | // test('uses a proper close event', async t => { 68 | // await new Promise(async resolve => { 69 | // const server = await newHocuspocus() 70 | 71 | // newHocuspocusProvider(server, { 72 | // name: 'hocuspocus-test', 73 | // onSynced() { 74 | // server.closeConnections() 75 | // }, 76 | // onClose({ event }) { 77 | // // Make sure it doesn’t reconnect. 78 | // t.is(event.code, 1000) 79 | // t.is(event.reason, 'Reset Connection') 80 | 81 | // resolve('done') 82 | // }, 83 | // }) 84 | // }) 85 | // }) 86 | -------------------------------------------------------------------------------- /tests/server/getDocumentsCount.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { 3 | newHocuspocus, newHocuspocusProvider, randomInteger, 4 | } from '../utils/index.ts' 5 | import { retryableAssertion } from '../utils/retryableAssertion.ts' 6 | 7 | test('documents count is zero by default', async t => { 8 | const server = await newHocuspocus() 9 | 10 | t.is(server.getDocumentsCount(), 0) 11 | }) 12 | 13 | test('documents count is 1 when one provider is connected', async t => { 14 | await new Promise(async resolve => { 15 | const server = await newHocuspocus() 16 | 17 | newHocuspocusProvider(server, { 18 | onSynced() { 19 | t.is(server.getDocumentsCount(), 1) 20 | 21 | resolve('done') 22 | }, 23 | }) 24 | }) 25 | }) 26 | 27 | test('the same document name counts as one document', async t => { 28 | const server = await newHocuspocus() 29 | 30 | const providers = [ 31 | newHocuspocusProvider(server, { name: 'foobar' }), 32 | newHocuspocusProvider(server, { name: 'foobar' }), 33 | ] 34 | 35 | await retryableAssertion(t, tt => { 36 | tt.is(server.getDocumentsCount(), 1) 37 | }) 38 | 39 | providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() }) 40 | 41 | await retryableAssertion(t, tt => { 42 | tt.is(server.getConnectionsCount(), 0) 43 | }) 44 | }) 45 | 46 | test('adds and removes different documents properly', async t => { 47 | const server = await newHocuspocus() 48 | 49 | const providers = [ 50 | newHocuspocusProvider(server, { name: 'foo-1' }), 51 | newHocuspocusProvider(server, { name: 'foo-2' }), 52 | newHocuspocusProvider(server, { name: 'foo-3' }), 53 | newHocuspocusProvider(server, { name: 'foo-4' }), 54 | newHocuspocusProvider(server, { name: 'foo-5' }), 55 | ] 56 | 57 | await retryableAssertion(t, tt => { 58 | tt.is(server.getDocumentsCount(), 5) 59 | }) 60 | 61 | providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() }) 62 | 63 | await retryableAssertion(t, tt => { 64 | tt.is(server.getConnectionsCount(), 0) 65 | }) 66 | }) 67 | 68 | test('adds and removes random number of documents properly', async t => { 69 | // random number of providers 70 | const server = await newHocuspocus() 71 | const numberOfProviders = randomInteger(10, 100) 72 | const providers = [] 73 | for (let index = 0; index < numberOfProviders; index += 1) { 74 | providers.push( 75 | newHocuspocusProvider(server, { name: `foobar-${index}` }), 76 | ) 77 | } 78 | await retryableAssertion(t, tt => { 79 | tt.is(server.getDocumentsCount(), numberOfProviders) 80 | }) 81 | 82 | // random number of disconnects 83 | const numberOfDisconnects = randomInteger(1, numberOfProviders) 84 | for (let index = 0; index < numberOfDisconnects; index += 1) { 85 | providers[index].disconnect() 86 | providers[index].configuration.websocketProvider.disconnect() 87 | } 88 | 89 | // check the count 90 | await retryableAssertion(t, tt => { 91 | tt.is(server.getConnectionsCount(), numberOfProviders - numberOfDisconnects) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tests/server/listen.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { Server } from '@hocuspocus/server' 3 | import { newHocuspocus } from '../utils/index.ts' 4 | 5 | test('should respond with OK', async t => { 6 | const hocuspocus = await newHocuspocus() 7 | 8 | const response = await fetch(hocuspocus.server!.httpURL) 9 | 10 | t.is(await response.text(), 'Welcome to Hocuspocus!') 11 | }) 12 | 13 | test('should respond with status 200', async t => { 14 | const hocuspocus = await newHocuspocus() 15 | 16 | const response = await fetch(hocuspocus.server!.httpURL) 17 | 18 | t.is(await response.status, 200) 19 | }) 20 | 21 | test('should respond with OK on a custom port', async t => { 22 | const hocuspocus = await newHocuspocus({ 23 | port: 4000, 24 | }) 25 | 26 | const response = await fetch(hocuspocus.server!.httpURL) 27 | 28 | t.is(hocuspocus.server!.address.port, 4000) 29 | t.is(await response.text(), 'Welcome to Hocuspocus!') 30 | }) 31 | 32 | test('should respond with OK on a custom port passed to listen()', async t => { 33 | const server = new Server({ 34 | port: 0, 35 | }) 36 | 37 | server.listen(4001) 38 | 39 | const response = await fetch(server.httpURL) 40 | 41 | t.is(server.address.port, 4001) 42 | t.is(await response.text(), 'Welcome to Hocuspocus!') 43 | }) 44 | 45 | test('should take a custom port and a callback', async t => { 46 | const server = new Server({ 47 | port: 0, 48 | }) 49 | 50 | await new Promise(async resolve => { 51 | server.listen(4002, () => { 52 | resolve('done') 53 | }) 54 | }) 55 | 56 | const response = await fetch(server.httpURL) 57 | 58 | t.is(server.address.port, 4002) 59 | t.is(await response.text(), 'Welcome to Hocuspocus!') 60 | }) 61 | 62 | test('should execute a callback', async t => { 63 | const server = new Server({ 64 | port: 0, 65 | }) 66 | 67 | await new Promise(async resolve => { 68 | server.listen(0, async () => { 69 | resolve('done') 70 | }) 71 | }) 72 | 73 | const response = await fetch(server.httpURL) 74 | 75 | t.is(await response.text(), 'Welcome to Hocuspocus!') 76 | }) 77 | 78 | test('should have the custom port as a parameter in the callback', async t => { 79 | const server = new Server({ 80 | port: 0, 81 | }) 82 | 83 | await new Promise(async resolve => { 84 | server.listen(0, async ({ port }: any) => { 85 | t.is(port, server.address.port) 86 | resolve('done') 87 | }) 88 | }) 89 | 90 | const response = await fetch(server.httpURL) 91 | 92 | t.is(await response.text(), 'Welcome to Hocuspocus!') 93 | }) 94 | -------------------------------------------------------------------------------- /tests/server/onAwarenessUpdate.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { onAwarenessUpdatePayload } from '@hocuspocus/server' 3 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 4 | 5 | test('executes the onAwarenessUpdate callback', async t => { 6 | await new Promise(async resolve => { 7 | const server = await newHocuspocus({ 8 | async onAwarenessUpdate({ states }) { 9 | t.is(states.length, 1) 10 | t.is(states[0].foo, 'bar') 11 | 12 | resolve('done') 13 | }, 14 | }) 15 | 16 | const provider = newHocuspocusProvider(server, { 17 | onConnect() { 18 | provider.setAwarenessField('foo', 'bar') 19 | }, 20 | }) 21 | }) 22 | }) 23 | 24 | test('executes the onAwarenessUpdate callback from a custom extension', async t => { 25 | await new Promise(async resolve => { 26 | class CustomExtension { 27 | async onAwarenessUpdate({ states }: onAwarenessUpdatePayload) { 28 | t.is(states.length, 1) 29 | t.is(states[0].foo, 'bar') 30 | 31 | resolve('done') 32 | } 33 | } 34 | 35 | const server = await newHocuspocus({ 36 | extensions: [ 37 | new CustomExtension(), 38 | ], 39 | }) 40 | 41 | const provider = newHocuspocusProvider(server, { 42 | onConnect() { 43 | provider.setAwarenessField('foo', 'bar') 44 | }, 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /tests/server/onClose.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { WebSocketStatus } from '@hocuspocus/provider' 3 | import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts' 4 | import { retryableAssertion } from '../utils/retryableAssertion.ts' 5 | 6 | test('server closes connection when receiving close event from provider', async t => { 7 | await new Promise(async resolve => { 8 | const server = await newHocuspocus({}) 9 | const socket = newHocuspocusProviderWebsocket(server, {}) 10 | 11 | const provider1 = newHocuspocusProvider(server, { 12 | websocketProvider: socket, 13 | name: 'hocuspocus-test', 14 | }) 15 | 16 | await retryableAssertion(t, t2 => { 17 | t2.is(server.getConnectionsCount(), 1) 18 | }) 19 | 20 | await retryableAssertion(t, t2 => { 21 | provider1.destroy() 22 | t2.is(server.getConnectionsCount(), 0) 23 | }) 24 | 25 | resolve('ok') 26 | }) 27 | }) 28 | 29 | test('server doesnt close connection after receiving close event from all connections', async t => { 30 | await new Promise(async resolve => { 31 | const server = await newHocuspocus({}) 32 | const socket = newHocuspocusProviderWebsocket(server, {}) 33 | 34 | const provider1 = newHocuspocusProvider(server, { 35 | websocketProvider: socket, 36 | name: 'hocuspocus-test', 37 | }) 38 | 39 | const provider2 = newHocuspocusProvider(server, { 40 | websocketProvider: socket, 41 | name: 'hocuspocus-test2', 42 | }) 43 | 44 | await retryableAssertion(t, t2 => { 45 | t2.is(server.getConnectionsCount(), 1) 46 | }) 47 | 48 | socket.shouldConnect = false 49 | provider1.destroy() 50 | 51 | t.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected) 52 | t.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected) 53 | 54 | setTimeout(async () => { 55 | t.is(server.getConnectionsCount(), 1) 56 | provider2.destroy() 57 | 58 | t.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected) 59 | t.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected) 60 | 61 | await retryableAssertion(t, t2 => { 62 | t2.is(server.getConnectionsCount(), 1) 63 | t2.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected) 64 | t2.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected) 65 | }) 66 | 67 | resolve('ok') 68 | }, 200) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /tests/server/onConfigure.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { Hocuspocus } from '@hocuspocus/server' 3 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 4 | 5 | test('onConfigure callback is executed', async t => { 6 | await new Promise(async resolve => { 7 | let givenInstance = null 8 | 9 | const server = await newHocuspocus({ 10 | async onConfigure({ instance }) { 11 | givenInstance = instance 12 | }, 13 | }) 14 | 15 | t.is(givenInstance as unknown as Hocuspocus, server) 16 | resolve('done') 17 | }) 18 | }) 19 | 20 | test('executes onConfigure callback from an extension', async t => { 21 | await new Promise(async resolve => { 22 | class CustomExtension { 23 | async onConfigure() { 24 | t.pass() 25 | resolve('done') 26 | } 27 | } 28 | 29 | const server = await newHocuspocus({ 30 | extensions: [ 31 | new CustomExtension(), 32 | ], 33 | }) 34 | 35 | newHocuspocusProvider(server) 36 | }) 37 | }) 38 | 39 | test('has the configuration', async t => { 40 | await new Promise(async resolve => { 41 | newHocuspocus({ 42 | debounce: 2001, 43 | async onConfigure({ configuration }) { 44 | t.is(configuration.debounce, 2001) 45 | 46 | resolve('done') 47 | }, 48 | }) 49 | }) 50 | }) 51 | 52 | test('has the version', async t => { 53 | await new Promise(async resolve => { 54 | newHocuspocus({ 55 | async onConfigure({ version }) { 56 | t.truthy(version) 57 | 58 | resolve('done') 59 | }, 60 | }) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tests/server/onDisconnect.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onDisconnect callback', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus({ 7 | async onDisconnect() { 8 | t.pass() 9 | resolve('done') 10 | }, 11 | }) 12 | 13 | const provider = newHocuspocusProvider(server, { 14 | onConnect() { 15 | provider.configuration.websocketProvider.disconnect() 16 | provider.disconnect() 17 | }, 18 | }) 19 | }) 20 | }) 21 | 22 | test('executes the onDisconnect callback from an extension', async t => { 23 | await new Promise(async resolve => { 24 | const server = await newHocuspocus() 25 | 26 | class CustomExtension { 27 | async onDisconnect() { 28 | t.pass() 29 | resolve('done') 30 | } 31 | } 32 | 33 | server.configure({ 34 | extensions: [ 35 | new CustomExtension(), 36 | ], 37 | }) 38 | 39 | const provider = newHocuspocusProvider(server, { 40 | 41 | onConnect() { 42 | provider.configuration.websocketProvider.disconnect() 43 | provider.disconnect() 44 | }, 45 | }) 46 | }) 47 | }) 48 | 49 | test('passes the context to the onLoadDocument callback', async t => { 50 | await new Promise(async resolve => { 51 | const server = await newHocuspocus() 52 | 53 | const mockContext = { 54 | user: 123, 55 | } 56 | 57 | server.configure({ 58 | async onConnect() { 59 | return mockContext 60 | }, 61 | async onDisconnect({ context }) { 62 | t.deepEqual(context, mockContext) 63 | 64 | resolve('done') 65 | }, 66 | }) 67 | 68 | const provider = newHocuspocusProvider(server, { 69 | 70 | onConnect() { 71 | provider.configuration.websocketProvider.disconnect() 72 | provider.disconnect() 73 | }, 74 | }) 75 | }) 76 | }) 77 | 78 | test('has the server instance', async t => { 79 | await new Promise(async resolve => { 80 | const server = await newHocuspocus({ 81 | async onDisconnect({ instance }) { 82 | t.is(instance, server) 83 | 84 | resolve('done') 85 | }, 86 | }) 87 | 88 | const provider = newHocuspocusProvider(server, { 89 | 90 | onConnect() { 91 | provider.configuration.websocketProvider.disconnect() 92 | provider.disconnect() 93 | }, 94 | }) 95 | }) 96 | }) 97 | 98 | test('the connections count is correct', async t => { 99 | await new Promise(async resolve => { 100 | const server = await newHocuspocus({ 101 | async onDisconnect() { 102 | t.is(server.getConnectionsCount(), 0) 103 | resolve('done') 104 | }, 105 | }) 106 | 107 | const provider = newHocuspocusProvider(server, { 108 | onConnect() { 109 | provider.configuration.websocketProvider.disconnect() 110 | provider.disconnect() 111 | }, 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /tests/server/onListen.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus } from '../utils/index.ts' 3 | 4 | test('executes the onListen callback', async t => { 5 | await new Promise(async resolve => { 6 | newHocuspocus({ 7 | async onListen() { 8 | t.pass() 9 | resolve('done') 10 | }, 11 | }) 12 | }) 13 | }) 14 | 15 | test('executes the onListen callback from an extension', async t => { 16 | await new Promise(async resolve => { 17 | class CustomExtension { 18 | async onListen() { 19 | t.pass() 20 | resolve('done') 21 | } 22 | } 23 | 24 | newHocuspocus({ 25 | extensions: [ 26 | new CustomExtension(), 27 | ], 28 | }) 29 | }) 30 | }) 31 | 32 | test('has the configuration', async t => { 33 | await new Promise(async resolve => { 34 | newHocuspocus({ 35 | async onListen({ configuration }) { 36 | t.is(configuration.quiet, true) 37 | resolve('done') 38 | }, 39 | }) 40 | }) 41 | }) 42 | 43 | test('has the port', async t => { 44 | await new Promise(async resolve => { 45 | newHocuspocus({ 46 | async onListen({ port }) { 47 | t.truthy(port) 48 | resolve('done') 49 | }, 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /tests/server/onRequest.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { onRequestPayload } from '@hocuspocus/server' 3 | import { newHocuspocus } from '../utils/index.ts' 4 | 5 | test('executes the onRequest callback', async t => { 6 | await new Promise(async resolve => { 7 | const hocuspocus = await newHocuspocus({ 8 | async onRequest({ request }: onRequestPayload) { 9 | t.is(request.url, '/foobar') 10 | 11 | resolve('done') 12 | }, 13 | }) 14 | 15 | await fetch(`${hocuspocus.server!.httpURL}/foobar`) 16 | }) 17 | }) 18 | 19 | test('executes the onRequest callback of a custom extension', async t => { 20 | await new Promise(async resolve => { 21 | class CustomExtension { 22 | async onRequest({ response }: onRequestPayload) { 23 | return new Promise((resolve, reject) => { 24 | 25 | response.writeHead(200, { 'Content-Type': 'text/plain' }) 26 | response.end('I like cats.') 27 | 28 | return reject() 29 | }) 30 | } 31 | } 32 | 33 | const hocuspocus = await newHocuspocus({ 34 | extensions: [ 35 | new CustomExtension(), 36 | ], 37 | }) 38 | 39 | const response = await fetch(hocuspocus.server!.httpURL) 40 | t.is(await response.text(), 'I like cats.') 41 | resolve('done') 42 | }) 43 | }) 44 | 45 | test('can intercept specific URLs', async t => { 46 | await new Promise(async resolve => { 47 | const hocuspocus = await newHocuspocus({ 48 | async onRequest({ response, request }: onRequestPayload) { 49 | if (request.url === '/foobar') { 50 | return new Promise((resolve, reject) => { 51 | 52 | response.writeHead(200, { 'Content-Type': 'text/plain' }) 53 | response.end('I like cats.') 54 | 55 | return reject() 56 | }) 57 | } 58 | }, 59 | }) 60 | 61 | const interceptedResponse = await fetch(`${hocuspocus.server!.httpURL}/foobar`) 62 | t.is(await interceptedResponse.text(), 'I like cats.') 63 | 64 | const regularResponse = await fetch(hocuspocus.server!.httpURL) 65 | t.is(await regularResponse.text(), 'Welcome to Hocuspocus!') 66 | resolve('done') 67 | }) 68 | }) 69 | 70 | test('has the instance', async t => { 71 | await new Promise(async resolve => { 72 | const hocuspocus = await newHocuspocus({ 73 | async onRequest({ instance }) { 74 | t.is(instance, hocuspocus) 75 | resolve('done') 76 | }, 77 | }) 78 | 79 | await fetch(`${hocuspocus.server!.httpURL}/foobar`) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /tests/server/onUpgrade.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts' 3 | 4 | test('executes the onUpgrade callback', async t => { 5 | await new Promise(async resolve => { 6 | const server = await newHocuspocus({ 7 | async onUpgrade() { 8 | t.pass() 9 | resolve('done') 10 | }, 11 | }) 12 | 13 | newHocuspocusProvider(server) 14 | }) 15 | }) 16 | 17 | test('executes the onUpgrade callback from an extension', async t => { 18 | await new Promise(async resolve => { 19 | class CustomExtension { 20 | async onUpgrade() { 21 | t.pass() 22 | resolve('done') 23 | } 24 | } 25 | 26 | const server = await newHocuspocus({ 27 | extensions: [ 28 | new CustomExtension(), 29 | ], 30 | }) 31 | 32 | newHocuspocusProvider(server) 33 | }) 34 | }) 35 | 36 | test('has the server instance', async t => { 37 | await new Promise(async resolve => { 38 | const server = await newHocuspocus({ 39 | 40 | async onUpgrade({ instance }) { 41 | t.is(instance, server) 42 | resolve('done') 43 | }, 44 | }) 45 | 46 | newHocuspocusProvider(server) 47 | }) 48 | }) 49 | 50 | test('has the request', async t => { 51 | await new Promise(async resolve => { 52 | const server = await newHocuspocus({ 53 | 54 | async onUpgrade({ request }) { 55 | t.is(request.url, '/') 56 | resolve('done') 57 | }, 58 | }) 59 | 60 | newHocuspocusProvider(server) 61 | }) 62 | }) 63 | 64 | test('has the socket', async t => { 65 | await new Promise(async resolve => { 66 | const server = await newHocuspocus({ 67 | 68 | async onUpgrade({ socket }) { 69 | t.truthy(socket) 70 | resolve('done') 71 | }, 72 | }) 73 | 74 | newHocuspocusProvider(server) 75 | }) 76 | }) 77 | 78 | test('has the head', async t => { 79 | await new Promise(async resolve => { 80 | const server = await newHocuspocus({ 81 | 82 | async onUpgrade({ head }) { 83 | t.truthy(head) 84 | resolve('done') 85 | }, 86 | }) 87 | 88 | newHocuspocusProvider(server) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/server/websocketError.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import type { onAuthenticatePayload } from '@hocuspocus/server' 3 | import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts' 4 | 5 | test('does not crash when invalid opcode is sent', async t => { 6 | await new Promise(async resolve => { 7 | const server = await newHocuspocus() 8 | 9 | const socket = newHocuspocusProviderWebsocket(server) 10 | 11 | const provider = newHocuspocusProvider(server, { 12 | websocketProvider: socket, 13 | onSynced({ state }) { 14 | socket.shouldConnect = false 15 | 16 | // Send a bad opcode via the low level internal _socket 17 | // Inspired by https://github.com/websockets/ws/blob/975382178f8a9355a5a564bb29cb1566889da9ba/test/websocket.test.js#L553-L589 18 | 19 | if (state) { 20 | // @ts-ignore 21 | socket.webSocket!._socket.write(Buffer.from([0x00, 0x00])) // eslint-disable-line 22 | } 23 | }, 24 | onClose({ event }) { 25 | 26 | t.is(event.code, 1002) 27 | try { 28 | socket.destroy() 29 | // eslint-disable-next-line no-empty 30 | } catch (e) { 31 | 32 | } 33 | }, 34 | onDestroy() { 35 | t.pass() 36 | resolve(true) 37 | }, 38 | }) 39 | }) 40 | }) 41 | 42 | test('does not crash when invalid utf-8 sequence is sent pre-authentication', async t => { 43 | await new Promise(async resolve => { 44 | const server = await newHocuspocus({ 45 | async onAuthenticate(data: onAuthenticatePayload) { 46 | return new Promise(async resolve => { 47 | setTimeout(resolve, 2000) 48 | }) 49 | }, 50 | }) 51 | 52 | const socket = newHocuspocusProviderWebsocket(server) 53 | 54 | const provider = newHocuspocusProvider(server, { 55 | websocketProvider: socket, 56 | onClose({ event }) { 57 | t.is(event.code, 4401) 58 | provider.destroy() 59 | }, 60 | onDestroy() { 61 | t.pass() 62 | resolve(true) 63 | }, 64 | }) 65 | 66 | setInterval(() => { 67 | socket.webSocket!.send('ϩ') // eslint-disable-line 68 | }, 500) 69 | }) 70 | }) 71 | 72 | test('does not crash when invalid utf-8 sequence is sent post-authentication', async t => { 73 | await new Promise(async resolve => { 74 | const server = await newHocuspocus({ 75 | async onAuthenticate(data: onAuthenticatePayload) { 76 | return new Promise(async resolve => { 77 | setTimeout(resolve, 2000) 78 | }) 79 | }, 80 | }) 81 | 82 | const socket = newHocuspocusProviderWebsocket(server) 83 | 84 | const provider = newHocuspocusProvider(server, { 85 | websocketProvider: socket, 86 | token: 'test123', 87 | onClose({ event }) { 88 | t.is(event.code, 1002) 89 | provider.destroy() 90 | }, 91 | onDestroy() { 92 | t.pass() 93 | resolve(true) 94 | }, 95 | }) 96 | 97 | setInterval(() => { 98 | // @ts-ignore 99 | socket.webSocket!._socket.write(Buffer.from([0x81, 0x04, 0xce, 0xba, 0xe1, 0xbd])) // eslint-disable-line 100 | }, 500) 101 | 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /tests/transformer/TiptapTransformer.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { TiptapTransformer } from '@hocuspocus/transformer' 3 | 4 | test('transforms JSON to Y.Doc', async t => { 5 | const json = { 6 | type: 'doc', 7 | content: [ 8 | { 9 | type: 'paragraph', 10 | content: [ 11 | { 12 | type: 'text', 13 | text: 'Example Text', 14 | }, 15 | ], 16 | }, 17 | ], 18 | } 19 | 20 | const ydoc = TiptapTransformer.toYdoc(json, 'content') 21 | 22 | t.is( 23 | ydoc.getXmlFragment('content').toJSON(), 24 | 'Example Text', 25 | ) 26 | }) 27 | 28 | test('writes to the correct Y.Doc field', async t => { 29 | const json = { 30 | type: 'doc', 31 | content: [ 32 | { 33 | type: 'paragraph', 34 | content: [ 35 | { 36 | type: 'text', 37 | text: 'Example Text', 38 | }, 39 | ], 40 | }, 41 | ], 42 | } 43 | 44 | const ydoc = TiptapTransformer.toYdoc(json, 'mySuperCustomField') 45 | 46 | t.is( 47 | ydoc.getXmlFragment('mySuperCustomField').toJSON(), 48 | 'Example Text', 49 | ) 50 | }) 51 | 52 | test('throws a helpful error when the document is empty', async t => { 53 | const invalidJson = null 54 | 55 | const error = t.throws(() => { 56 | TiptapTransformer.toYdoc(invalidJson, 'content') 57 | }, { instanceOf: Error }) 58 | 59 | t.truthy(error?.message.includes('ProseMirror-compatible JSON')) 60 | }) 61 | 62 | test('throws a helpful error when the document is invalid', async t => { 63 | const invalidJson = { 64 | type: 'invalidType', 65 | content: [], 66 | } 67 | 68 | const error = t.throws(() => { 69 | TiptapTransformer.toYdoc(invalidJson, 'content') 70 | }, { instanceOf: Error }) 71 | 72 | t.truthy(error?.message.includes('Unknown node type: invalidType')) 73 | }) 74 | -------------------------------------------------------------------------------- /tests/utils/createDirectory.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | export const createDirectory = (dir: string) => { 4 | try { 5 | fs.mkdir(dir, () => {}) 6 | } catch { 7 | // 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/utils/flushRedis.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { createClient } from 'redis' 3 | import { redisConnectionSettings } from './redisConnectionSettings.ts' 4 | 5 | export const flushRedis = async () => { 6 | const client = createClient({ 7 | url: `redis://${redisConnectionSettings.host}:${redisConnectionSettings.port}`, 8 | }) 9 | 10 | await client.connect() 11 | 12 | return client.flushDb() 13 | } 14 | -------------------------------------------------------------------------------- /tests/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createDirectory.ts' 2 | export * from './flushRedis.ts' 3 | export * from './newHocuspocus.ts' 4 | export * from './newHocuspocusProvider.ts' 5 | export * from './newHocuspocusProviderWebsocket.ts' 6 | export * from './randomInteger.ts' 7 | export * from './redisConnectionSettings.ts' 8 | export * from './removeDirectory.ts' 9 | export * from './sleep.ts' 10 | -------------------------------------------------------------------------------- /tests/utils/newHocuspocus.ts: -------------------------------------------------------------------------------- 1 | import type { ServerConfiguration } from '@hocuspocus/server' 2 | import { Server } from '@hocuspocus/server' 3 | 4 | export const newHocuspocus = (options?: Partial) => { 5 | const server = new Server({ 6 | // We don’t need the logging in testing. 7 | quiet: true, 8 | // Binding something port 0 will end up on a random free port. 9 | // That’s helpful to run tests concurrently. 10 | port: 0, 11 | // Add or overwrite settings, depending on the test case. 12 | ...options, 13 | }) 14 | 15 | return server.listen() 16 | } 17 | -------------------------------------------------------------------------------- /tests/utils/newHocuspocusProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HocuspocusProvider, 3 | type HocuspocusProviderConfiguration, 4 | type HocuspocusProviderWebsocket, 5 | type HocuspocusProviderWebsocketConfiguration, 6 | } from '@hocuspocus/provider' 7 | import type { Hocuspocus } from '@hocuspocus/server' 8 | import { newHocuspocusProviderWebsocket } from './newHocuspocusProviderWebsocket.ts' 9 | 10 | export const newHocuspocusProvider = ( 11 | server: Hocuspocus, 12 | options: Partial = {}, 13 | websocketOptions: Partial = {}, 14 | websocketProvider?: HocuspocusProviderWebsocket, 15 | ): HocuspocusProvider => { 16 | const provider = new HocuspocusProvider({ 17 | websocketProvider: websocketProvider ?? newHocuspocusProviderWebsocket(server, websocketOptions), 18 | // Just use a generic document name for all tests. 19 | name: 'hocuspocus-test', 20 | // Add or overwrite settings, depending on the test case. 21 | ...options, 22 | }) 23 | provider.attach() 24 | 25 | return provider 26 | } 27 | -------------------------------------------------------------------------------- /tests/utils/newHocuspocusProviderWebsocket.ts: -------------------------------------------------------------------------------- 1 | import type { HocuspocusProviderWebsocketConfiguration} from '@hocuspocus/provider' 2 | import { 3 | HocuspocusProviderWebsocket, 4 | } from '@hocuspocus/provider' 5 | import type { Hocuspocus } from '@hocuspocus/server' 6 | import WebSocket from 'ws' 7 | 8 | export const newHocuspocusProviderWebsocket = ( 9 | hocuspocus: Hocuspocus, 10 | options: Partial> = {}, 11 | ) => { 12 | return new HocuspocusProviderWebsocket({ 13 | // We don’t need which port the server is running on, but 14 | // we can get the URL from the passed server instance. 15 | url: hocuspocus.server!.webSocketURL, 16 | // Pass a polyfill to use WebSockets in a Node.js environment. 17 | WebSocketPolyfill: WebSocket, 18 | ...options, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /tests/utils/randomInteger.ts: -------------------------------------------------------------------------------- 1 | export const randomInteger = (min: number, max: number) => { 2 | return Math.floor(Math.random() * (max - min + 1) + min) 3 | } 4 | -------------------------------------------------------------------------------- /tests/utils/redisConnectionSettings.ts: -------------------------------------------------------------------------------- 1 | export const redisConnectionSettings = { 2 | host: process.env.REDIS_HOST || '127.0.0.1', 3 | port: parseInt(process.env.REDIS_PORT || '', 10) || 6379, 4 | } 5 | -------------------------------------------------------------------------------- /tests/utils/removeDirectory.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | export const removeDirectory = (dir: string) => { 5 | try { 6 | const list = fs.readdirSync(dir) 7 | 8 | for (let i = 0; i < list.length; i += 1) { 9 | const filename = path.join(dir, list[i]) 10 | const stat = fs.statSync(filename) 11 | 12 | if (filename === '.' || filename === '..') { 13 | // pass these files 14 | } else if (stat.isDirectory()) { 15 | // rmdir recursively 16 | removeDirectory(filename) 17 | } else { 18 | // rm fiilename 19 | fs.unlinkSync(filename) 20 | } 21 | } 22 | 23 | fs.rmdirSync(dir) 24 | } catch { 25 | // 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/utils/retryableAssertion.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionContext } from 'ava' 2 | import { sleep } from './sleep.ts' 3 | 4 | /* eslint-disable no-await-in-loop */ 5 | export const retryableAssertion = async (t: ExecutionContext, recoverableTry: (tt: ExecutionContext) => void) => { 6 | // eslint-disable-next-line no-constant-condition 7 | while (true) { 8 | const lastTry = await t.try(recoverableTry) 9 | 10 | if (lastTry.passed) { 11 | lastTry.commit() 12 | break 13 | } 14 | lastTry.discard() 15 | 16 | await sleep(100) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (time: number) => { 2 | return new Promise(async resolve => { 3 | setTimeout(resolve, time) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "resolveJsonModule": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "rewriteRelativeImportExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | "experimentalDecorators": true, 16 | "sourceMap": true, 17 | "baseUrl": ".", 18 | "rootDir": ".", 19 | "allowJs": false, 20 | "checkJs": false, 21 | "paths": { 22 | "@hocuspocus/*": [ 23 | "packages/*/src/index.ts", 24 | ] 25 | }, 26 | "lib": [ 27 | "esnext", 28 | "dom" 29 | ], 30 | "skipLibCheck": true 31 | }, 32 | "include": [ 33 | "**/*.ts" 34 | ], 35 | "exclude": [ 36 | "**/node_modules", 37 | "**/dist" 38 | ] 39 | } 40 | --------------------------------------------------------------------------------