From 90e9a256586d3834a7cb34de100cdcca04538749 Mon Sep 17 00:00:00 2001 From: timoisik Date: Mon, 2 Sep 2024 21:19:31 +0200 Subject: [PATCH] WIP: refactor to separate server and hocuspocus (#686) * WIP: refactoring hocuspocus and moved listen function to server instead * Moved port and address from hocuspocus to server entirely * WIP Fixing tests * Fixing tests * Removed port from onConfigure test * Fixed tests * Moved destroy also to server * Updated documentation * WIP Upgrade guide * Added upgrade to listen method on server * Fixed typos * change URL of Tiptap Cloud --------- Co-authored-by: Patrick Baber Co-authored-by: Jan Thurau --- docs/getting-started.md | 4 +- docs/guides/auth.md | 4 +- docs/guides/custom-extensions.md | 2 +- docs/guides/multi-subdocuments.md | 6 +- docs/guides/persistence.md | 4 +- docs/guides/scalability.md | 2 +- docs/links.yaml | 17 + docs/provider/configuration.md | 46 +- docs/provider/events.md | 2 +- docs/provider/examples.md | 2 - docs/provider/introduction.md | 2 +- docs/server/configuration.md | 2 +- docs/server/examples.md | 26 +- docs/server/extensions.md | 659 +----------------- docs/server/extensions/database.md | 81 +++ docs/server/extensions/logger.md | 78 +++ docs/server/extensions/redis.md | 88 +++ docs/server/extensions/sqlite.md | 68 ++ docs/server/extensions/throttle.md | 36 + docs/server/extensions/webhook.md | 305 ++++++++ docs/server/hooks.md | 41 +- docs/server/methods.md | 31 +- docs/server/usage.md | 54 ++ docs/upgrade.md | 100 +++ packages/cli/src/index.js | 2 +- packages/provider/src/TiptapCollabProvider.ts | 2 +- .../src/TiptapCollabProviderWebsocket.ts | 2 +- packages/server/src/ClientConnection.ts | 2 +- packages/server/src/Hocuspocus.ts | 164 +---- packages/server/src/Server.ts | 168 ++++- packages/server/src/index.ts | 1 + packages/server/src/types.ts | 16 +- playground/backend/.gitignore | 1 + playground/backend/src/default.ts | 2 +- playground/backend/src/express.ts | 6 +- playground/backend/src/koa.ts | 6 +- playground/backend/src/load-document.ts | 2 +- playground/backend/src/redis.ts | 6 +- playground/backend/src/slow.ts | 2 +- playground/backend/src/webhook.ts | 2 +- tests/extension-logger/onListen.ts | 8 +- tests/server/address.ts | 8 +- tests/server/listen.ts | 28 +- tests/server/onConfigure.ts | 4 +- tests/server/onDestroy.ts | 60 +- tests/server/onRequest.ts | 20 +- tests/server/onStoreDocument.ts | 6 +- tests/utils/newHocuspocus.ts | 6 +- tests/utils/newHocuspocusProviderWebsocket.ts | 4 +- 49 files changed, 1182 insertions(+), 1006 deletions(-) create mode 100644 docs/server/extensions/database.md create mode 100644 docs/server/extensions/logger.md create mode 100644 docs/server/extensions/redis.md create mode 100644 docs/server/extensions/sqlite.md create mode 100644 docs/server/extensions/throttle.md create mode 100644 docs/server/extensions/webhook.md create mode 100644 docs/server/usage.md create mode 100644 docs/upgrade.md diff --git a/docs/getting-started.md b/docs/getting-started.md index 98bde0f4a..c77db42f5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,10 +23,10 @@ yarn add @hocuspocus/server y-protocols yjs ### Usage ```js -import { Hocuspocus } from "@hocuspocus/server"; +import { Server } from "@hocuspocus/server"; // Configure the server … -const server = new Hocuspocus({ +const server = new Server({ port: 1234, }); diff --git a/docs/guides/auth.md b/docs/guides/auth.md index 74612e8ea..72f9fa2b2 100644 --- a/docs/guides/auth.md +++ b/docs/guides/auth.md @@ -17,7 +17,7 @@ For more information on the hook and its payload, check out its [section](/serve ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onAuthenticate(data) { const { token } = data; @@ -60,7 +60,7 @@ import { Server } from "@hocuspocus/server"; const usersWithWriteAccess = ["jane", "john", "christina"]; -const server = Server.configure({ +const server = new Server({ async onAuthenticate(data): Doc { // Example code to check if the current user has write access by a // request parameter. In a real world application you would probably diff --git a/docs/guides/custom-extensions.md b/docs/guides/custom-extensions.md index 22ef56bdd..f73fd8377 100644 --- a/docs/guides/custom-extensions.md +++ b/docs/guides/custom-extensions.md @@ -116,7 +116,7 @@ That's it. The only thing missing now is your code. Happy extension writing! Whe import { Server } from "@hocuspocus/server"; import { MyHocuspocusExtension } from "./extensions/my-hocuspocus-extension"; -const server = Server.configure({ +const server = new Server({ extensions: [ new MyHocuspocusExtension({ myConfigurationOption: "baz", diff --git a/docs/guides/multi-subdocuments.md b/docs/guides/multi-subdocuments.md index a7175b57a..e2135c988 100644 --- a/docs/guides/multi-subdocuments.md +++ b/docs/guides/multi-subdocuments.md @@ -27,7 +27,7 @@ console.log(entityType); // prints "page" console.log(entityID); // prints "140 ``` -This is a recommendation, of course you can name your documents however you want! +This is a recommendation, of course you can name your documents whatever you want! ## Nested documents @@ -70,6 +70,7 @@ import { TiptapTransformer } from "@hocuspocus/transformer"; import Document from "@tiptap/extension-document"; import Paragraph from "@tiptap/extension-paragraph"; import Text from "@tiptap/extension-text"; + const generateSampleProsemirrorJson = (text: string) => { return { type: "doc", @@ -86,7 +87,8 @@ const generateSampleProsemirrorJson = (text: string) => { ], }; }; -const server = Server.configure({ + +const server = new Server({ async onLoadDocument(data) { // only import things if they are not already set in the primary storage if (data.document.isEmpty("default")) { diff --git a/docs/guides/persistence.md b/docs/guides/persistence.md index 2ea069090..0d6f295bf 100644 --- a/docs/guides/persistence.md +++ b/docs/guides/persistence.md @@ -9,7 +9,7 @@ To persist the documents you must instruct the server to: 1. Store the document in the `onStoreDocument` hook (which is the same as the `onChange` but with debounce already configured). 2. Load the document from the database using the `onLoadDocument` hook. -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/hocuspocus/server/extensions#database). +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](/server/extensions/database). 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). @@ -20,7 +20,7 @@ import { Doc } from "yjs"; let debounced; -const server = Server.configure({ +const server = new Server({ async onStoreDocument(data) { // Save to database. Example: // saveToDatabase(data.document, data.documentName); diff --git a/docs/guides/scalability.md b/docs/guides/scalability.md index 78d92d7fd..0f50b3a3c 100644 --- a/docs/guides/scalability.md +++ b/docs/guides/scalability.md @@ -7,7 +7,7 @@ tableOfContents: true ## Introduction If you are trying to deploy Hocuspocus in a HA setup or solve issues due to too many connections / network traffic, -you can use our redis extension: [extension-redis](/server/extensions#redis). +you can use our redis extension: [extension-redis](/server/extensions/redis). Yjs is really efficient (see https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/), so if you're having issues about cpu / memory usage, our suggested solution at the moment is to deploy multiple independent Hocuspocus instances and split users by a document diff --git a/docs/links.yaml b/docs/links.yaml index 8303bf66c..4ca52fd44 100644 --- a/docs/links.yaml +++ b/docs/links.yaml @@ -9,6 +9,8 @@ type: sponsor - title: Contributing link: /contributing + - title: Upgrade Guide + link: /upgrade - title: Server items: @@ -17,12 +19,27 @@ type: new - title: Configuration link: /server/configuration + - title: Usage + link: /server/usage - title: Methods link: /server/methods - title: Hooks link: /server/hooks - title: Extensions link: /server/extensions + items: + - title: Database + link: /server/extensions/database + - title: SQLite + link: /server/extensions/sqlite + - title: Redis + link: /server/extensions/redis + - title: Logger + link: /server/extensions/logger + - title: Webhook + link: /server/extensions/webhook + - title: Throttle + link: /server/extensions/throttle - title: Examples link: /server/examples diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index ca9e81717..364959c73 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -6,29 +6,29 @@ tableOfContents: true ## Settings -| Setting | Description | Default Value | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `url` | The URL of the Hocuspocus/WebSocket server. | `''` | -| `parameters` | Parameters will be added to the server URL and passed to the server. | `{}` | -| `name` | The name of the document. | `''` | -| `document` | The actual Y.js document. Optional, by default a new document is created and be access through provider.document. | `new Y.Doc()` | -| `token` | An authentication token that will be passed to the server (works with strings, functions and Promises). | `''` | -| `awareness` | Awareness object, by default attached to the passed Y.js document. | `new Awareness()` | -| `connect` | Whether to connect to the server after initialization. | `true` | -| `preserveConnection` | Whether to preserve the websocket connection after closing the provider. | `true` | -| `broadcast` | By default changes are synced between browser tabs through broadcasting. | `true` | -| `forceSyncInterval` | Ask the server every x ms for updates. | `false` | -| `delay` | The delay between each attempt in milliseconds. You can provide a factor to have the delay grow exponentially. | `1000` | -| `initialDelay` | The intialDelay is the amount of time to wait before making the first attempt. This option should typically be 0 since you typically want the first attempt to happen immediately. | `0` | -| `factor` | The factor option is used to grow the delay exponentially. | `2` | -| `maxAttempts` | The maximum number of attempts or 0 if there is no limit on number of attempts. | `0` | -| `minDelay` | minDelay is used to set a lower bound of delay when jitter is enabled. This property has no effect if jitter is disabled. | `1000` | -| `maxDelay` | The maxDelay option is used to set an upper bound for the delay when factor is enabled. A value of 0 can be provided if there should be no upper bound when calculating delay. | `30000` | -| `jitter` | If jitter is true then the calculated delay will be a random integer value between minDelay and the calculated delay for the current iteration. | `true` | -| `timeout` | A timeout in milliseconds. If timeout is non-zero then a timer is set using setTimeout. If the timeout is triggered then future attempts will be aborted. | `0` | -| `messageReconnectTimeout` | Closes the connection when after the configured messageReconnectTimeout no message was received. | `30000` | -| `WebSocketPolyfill` | Running in Node.js: Pass a WebSocket polyfill, for example ws. | `WebSocket` | -| `quiet` | The provider will output a few warnings to help you. In case you want to disable those, just set quiet to true. | `false` | +| Setting | Description | Default Value | +|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------| +| `url` | The URL of the Hocuspocus/WebSocket server. | `''` | +| `parameters` | Parameters will be added to the server URL and passed to the server. | `{}` | +| `name` | The name of the document. | `''` | +| `document` | The actual Y.js document. Optional, by default a new document is created and be access through provider.document. | `new Y.Doc()` | +| `token` | An authentication token that will be passed to the server (works with strings, functions and Promises). | `''` | +| `awareness` | Awareness object, by default attached to the passed Y.js document. | `new Awareness()` | +| `connect` | Whether to connect to the server after initialization. | `true` | +| `preserveConnection` | Whether to preserve the websocket connection after closing the provider. | `true` | +| `broadcast` | By default changes are synced between browser tabs through broadcasting. | `true` | +| `forceSyncInterval` | Ask the server every x ms for updates. | `false` | +| `delay` | The delay between each attempt in milliseconds. You can provide a factor to have the delay grow exponentially. | `1000` | +| `initialDelay` | The initialDelay is the amount of time to wait before making the first attempt. This option should typically be 0 since you typically want the first attempt to happen immediately. | `0` | +| `factor` | The factor option is used to grow the delay exponentially. | `2` | +| `maxAttempts` | The maximum number of attempts or 0 if there is no limit on number of attempts. | `0` | +| `minDelay` | minDelay is used to set a lower bound of delay when jitter is enabled. This property has no effect if jitter is disabled. | `1000` | +| `maxDelay` | The maxDelay option is used to set an upper bound for the delay when factor is enabled. A value of 0 can be provided if there should be no upper bound when calculating delay. | `30000` | +| `jitter` | If jitter is true then the calculated delay will be a random integer value between minDelay and the calculated delay for the current iteration. | `true` | +| `timeout` | A timeout in milliseconds. If timeout is non-zero then a timer is set using setTimeout. If the timeout is triggered then future attempts will be aborted. | `0` | +| `messageReconnectTimeout` | Closes the connection when after the configured messageReconnectTimeout no message was received. | `30000` | +| `WebSocketPolyfill` | Running in Node.js: Pass a WebSocket polyfill, for example ws. | `WebSocket` | +| `quiet` | The provider will output a few warnings to help you. In case you want to disable those, just set quiet to true. | `false` | ## Usage diff --git a/docs/provider/events.md b/docs/provider/events.md index 1c1f63213..e370f9f12 100644 --- a/docs/provider/events.md +++ b/docs/provider/events.md @@ -63,7 +63,7 @@ const provider = new HocuspocusProvider({ ``` ## Option 2: Binding -Sometimes you want to register an event listener after the intialization, even if it’s right after. Also, that’s a great way to bind and unbind event listeners. +Sometimes you want to register an event listener after the initialization, even if it’s right after. Also, that’s a great way to bind and unbind event listeners. ### Bind event listeners diff --git a/docs/provider/examples.md b/docs/provider/examples.md index 0751d8831..a70d5f3c4 100644 --- a/docs/provider/examples.md +++ b/docs/provider/examples.md @@ -260,6 +260,4 @@ const provider2 = new TiptapCollabProvider({ name: 'document2', token: '', }) - - ``` diff --git a/docs/provider/introduction.md b/docs/provider/introduction.md index bb8d662df..6d9b6b802 100644 --- a/docs/provider/introduction.md +++ b/docs/provider/introduction.md @@ -9,4 +9,4 @@ Providers are the Y.js way to set up communication between different users, or c 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. -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 instanciate multiple providers if you want to synchronize with Hocuspocus and other servers. +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. diff --git a/docs/server/configuration.md b/docs/server/configuration.md index 738b9c3de..adca01c88 100644 --- a/docs/server/configuration.md +++ b/docs/server/configuration.md @@ -24,7 +24,7 @@ There are only a few settings to pass for now. Most things are controlled throug ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ name: "hocuspocus-fra1-01", port: 1234, timeout: 30000, diff --git a/docs/server/examples.md b/docs/server/examples.md index be137afb4..09efd0aff 100644 --- a/docs/server/examples.md +++ b/docs/server/examples.md @@ -28,10 +28,10 @@ To use Hocuspocus with [Express](https://expressjs.com), you need to use the `ex ```js import express from "express"; import expressWebsockets from "express-ws"; -import { Server } from "@hocuspocus/server"; +import { Hocuspocus } from "@hocuspocus/server"; // Configure Hocuspocus -const server = Server.configure({ +const hocuspocus = new Hocuspocus({ // ... }); @@ -54,7 +54,7 @@ app.ws("/collaboration", (websocket, request) => { }, }; - server.handleConnection(websocket, request, context); + hocuspocus.handleConnection(websocket, request, context); }); // Start the server @@ -68,11 +68,11 @@ IMPORTANT! Some extensions use the `onRequest`, `onUpgrade` and `onListen` hooks ```js import Koa from "koa"; import websocket from "koa-easy-ws"; -import { Server } from "@hocuspocus/server"; +import { Hocuspocus } from "@hocuspocus/server"; import { Logger } from "@hocuspocus/extension-logger"; // Configure Hocuspocus -const server = Server.configure({ +const hocuspocus = new Hocuspocus({ // … }); @@ -87,7 +87,7 @@ app.use(websocket()); app.use(async (ctx, next) => { const ws = await ctx.ws(); - server.handleConnection( + hocuspocus.handleConnection( ws, ctx.request, // additional data (optional) @@ -149,11 +149,11 @@ const pool = mysql.createPool({ }); ``` -And then use the [database extension](https://tiptap.dev/hocuspocus/server/extensions#database) to store and retrieve the binary using `pool.query`. +And then use the [database extension](/server/extensions/database) to store and retrieve the binary using `pool.query`. ##### Option 1: Additionally storing the data in another format -Use the [webhook extension](https://tiptap.dev/hocuspocus/server/extensions#webhook) to send requests to Laravel when the document is updated, with the document in JSON format (see https://tiptap.dev/hocuspocus/guide/transformations#tiptap). +Use the [webhook extension](/server/extensions/webhook) to send requests to Laravel when the document is updated, with the document in JSON format (see https://tiptap.dev/hocuspocus/guide/transformations#tiptap). ##### Option 2: Retrieve the data on demand using a seperate nodejs daemon (advanced) @@ -170,9 +170,9 @@ Use the dotenv package as above to retrieve the mysql login details and perform You can use the webhook extension for auth - rejecting the `onConnect` request will cause the Hocuspocus server to disconnect - however for security critical applications it is better to use a custom `onAuthenicate` hook as an attacker may be able to retrieve some data from the Hocuspocus server before The `onConnect` hooks are rejected. -To authenticate with the Laravel server we can use Laravel's built in authentication system using the session cookie and a CSRF token. Add an onAuthenticate hook to your Hocuspocus server script which passes along the headers (and therefore the session cookie) and add the CSRF token to a request to the Laravel server: +To authenticate with the Laravel server we can use Laravel's built-in authentication system using the session cookie and a CSRF token. Add an onAuthenticate hook to your Hocuspocus server script which passes along the headers (and therefore the session cookie) and add the CSRF token to a request to the Laravel server: ``` -const hocusServer = Server.configure({ +const hocusServer = new Server({ ... onAuthenticate(data) { return new Promise((resolve, reject) => { @@ -200,7 +200,7 @@ const provider = new HocuspocusProvider({ token: '{{ csrf_token() }}', ``` -Finally, add a route in `api.php` to respond to the request. We can respond with an empty response and just use the request status to verify the authentication (i.e status code 200 or 403). This example uses the built-in Laravel middleware to verify the session cookie and csrf token. You can add any further middleware here as needed such as `verified` or any custom middleware: +Finally, add a route in `api.php` to respond to the request. We can respond with an empty response and just use the request status to verify the authentication (i.e. status code 200 or 403). This example uses the built-in Laravel middleware to verify the session cookie and csrf token. You can add any further middleware here as needed such as `verified` or any custom middleware: ``` Route::middleware(['web', 'auth'])->get('/hocus', function (Request $request) { return response(''); @@ -214,9 +214,9 @@ That's it! If you want to edit a document directly on the server (while keeping hooks and syncing running), the easiest way is to use Hocuspocus' `getDirectConnection` method. ```typescript -const server = new Hocuspocus(); +const hocuspocus = new Hocuspocus(); -const docConnection = await server.openDirectConnection('my-document', {}) +const docConnection = await hocuspocus.openDirectConnection('my-document', {}) await docConnection.transact((doc) => { doc.getMap('test').set('a', 'b'); diff --git a/docs/server/extensions.md b/docs/server/extensions.md index 184a23a66..58b6891b1 100644 --- a/docs/server/extensions.md +++ b/docs/server/extensions.md @@ -8,656 +8,9 @@ We already created some very useful extensions you should check out for sure: | Extension | Description | |-----------------------------------------| ------------------------------------------------------------------------------ | -| [Database](/server/extensions#database) | A generic database driver that is easily adjustable to work with any database. | -| [Logger](/server/extensions#logger) | Add logging to Hocuspocus. | -| [Redis](/server/extensions#redis) | Scale Hocuspocus horizontally with Redis. | -| [SQLite](/server/extensions#sq-lite) | Persist documents to SQLite. | -| [Throttle](/server/extensions#throttle) | Throttle connections by ips. | -| [Webhook](/server/extensions#webhook) | Send document changes via webhook to your API. | - -## Database - -Store your data in whatever data store you already have with the generic database extension. -It takes a Promise to fetch data and another Promise to store the data, that’s all. Hocuspocus will handle the rest. - -### Installation - -Install the database extension like this: - -```bash -npm install @hocuspocus/extension-database -``` - -### Configuration - -**fetch** - -Expects an async function (or Promise) which returns a Y.js compatible Uint8Array (or null). -Make sure to return the same Uint8Array that was saved in store(), and do not create a new Ydoc, -as doing so would lead to a new history (and duplicated content). - -If you want to initially create a Ydoc based off raw text/json, you can do so here using a transformer of your choice (e.g. `TiptapTransformer.toYdoc`, or `ProsemirrorTransformer.toYdoc`) - -**store** - -Expects an async function (or Promise) which persists the Y.js binary data somewhere. - -### Usage - -The following example uses SQLite to store and retrieve data. You can replace that part with whatever data store you have. As long as you return a Promise you can store data with PostgreSQL, MySQL, MongoDB, S3 … If you actually want to use SQLite, you can have a look at the [SQLite extension](/server/extensions#sq-lite). - -```js -import { Server } from "@hocuspocus/server"; -import { Database } from "@hocuspocus/extension-database"; -import sqlite3 from "sqlite3"; - -const server = Server.configure({ - extensions: [ - new Database({ - // Return a Promise to retrieve data … - fetch: async ({ documentName }) => { - return new Promise((resolve, reject) => { - this.db?.get( - ` - SELECT data FROM "documents" WHERE name = $name ORDER BY rowid DESC - `, - { - $name: documentName, - }, - (error, row) => { - if (error) { - reject(error); - } - - resolve(row?.data); - } - ); - }); - }, - // … and a Promise to store data: - store: async ({ documentName, state }) => { - this.db?.run( - ` - INSERT INTO "documents" ("name", "data") VALUES ($name, $data) - ON CONFLICT(name) DO UPDATE SET data = $data - `, - { - $name: documentName, - $data: state, - } - ); - }, - }), - ], -}); - -server.listen(); -``` - -## Logger - -Hocuspocus doesn’t log anything. Thanks to this simple extension it will. - -### Installation - -Install the Logger package with: - -```bash -npm install @hocuspocus/extension-logger -``` - -### Configuration - -**Instance name** - -You can prepend all logging messages with a configured string. - -```js -import { Server } from "@hocuspocus/server"; -import { Logger } from "@hocuspocus/extension-logger"; - -const server = Server.configure({ - name: "hocuspocus-fra1-01", - extensions: [new Logger()], -}); - -server.listen(); -``` - -**Disable messages** - -You can disable logging for specific messages. - -```js -import { Server } from "@hocuspocus/server"; -import { Logger } from "@hocuspocus/extension-logger"; - -const server = Server.configure({ - extensions: [ - new Logger({ - onLoadDocument: false, - onChange: false, - onConnect: false, - onDisconnect: false, - onUpgrade: false, - onRequest: false, - onListen: false, - onDestroy: false, - onConfigure: false, - }), - ], -}); - -server.listen(); -``` - -**Custom logger** - -You can even pass a custom function to log messages. - -```js -import { Server } from "@hocuspocus/server"; -import { Logger } from "@hocuspocus/extension-logger"; - -const server = Server.configure({ - extensions: [ - new Logger({ - log: (message) => { - // do something custom here - console.log(message); - }, - }), - ], -}); - -server.listen(); -``` - -## Redis - -Hocuspocus can be scaled horizontally using the Redis extension. You can spawn multiple instances of the server behind a -load balancer and sync changes and awareness states through Redis. Hocuspocus will propagate all received updates to all other instances -using Redis and thus forward updates to all clients of all Hocuspocus instances. - -The Redis extension does not persist data; it only syncs data between instances. Use the [Database](/server/extensions#database) extension to store your documents. - -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 -servers, you should not connect them via Redis. - -Thanks to [@tommoor](https://github.com/tommoor) for writing the initial implementation of that extension. - -### Installation - -Install the Redis extension with: - -```bash -npm install @hocuspocus/extension-redis -``` - -### Configuration - -For a full documentation on all available Redis and Redis cluster options, check out the -[ioredis API docs](https://github.com/luin/ioredis/blob/master/API.md). - -```js -import { Server } from "@hocuspocus/server"; -import { Redis } from "@hocuspocus/extension-redis"; - -const server = Server.configure({ - extensions: [ - new Redis({ - // [required] Hostname of your Redis instance - host: "127.0.0.1", - - // [required] Port of your Redis instance - port: 6379, - }), - ], -}); - -server.listen(); -``` - -### Usage - -The Redis extension works well with the database extension. Once an instance stores a document, it’s blocked for all other -instances to avoid write conflicts. - -```js -import { Hocuspocus } from "@hocuspocus/server"; -import { Logger } from "@hocuspocus/extension-logger"; -import { Redis } from "@hocuspocus/extension-redis"; -import { SQLite } from "@hocuspocus/extension-sqlite"; - -// Server 1 -const server = new Hocuspocus({ - name: "server-1", // make sure to use unique server names - port: 1234, - extensions: [ - new Logger(), - new Redis({ - host: "127.0.0.1", // make sure to use the same Redis instance :-) - port: 6379, - }), - new SQLite(), - ], -}); - -server.listen(); - -// Server 2 -const anotherServer = new Hocuspocus({ - name: "server-2", - port: 1235, - extensions: [ - new Logger(), - new Redis({ - host: "127.0.0.1", - port: 6379, - }), - new SQLite(), - ], -}); - -anotherServer.listen(); -``` - -## SQLite - -### Introduction - -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 extension is for. - -### Installation - -Install the SQLite extension like this: - -```bash -npm install @hocuspocus/extension-sqlite -``` - -### Configuration - -**database** - -Valid values are filenames, ":memory:" for an anonymous in-memory database and an empty -string for an anonymous disk-based database. Anonymous databases are not persisted and -when closing the database handle, their contents are lost. - -https://github.com/mapbox/node-sqlite3/wiki/API#new-sqlite3databasefilename-mode-callback - -Default: `:memory:` - -**schema** - -The SQLite schema that’s created for you. - -Default: - -```sql -CREATE TABLE IF NOT EXISTS "documents" ( - "name" varchar(255) NOT NULL, - "data" blob NOT NULL, - UNIQUE(name) -) -``` - -**fetch** - -An async function to retrieve data from SQLite. If you change the schema, you probably want to override the query. - -**store** - -An async function to store data in SQLite. If you change the schema, you probably want to override the query. - -**Usage** - -By default data is just “stored” in `:memory:`, so it’s wiped when you stop the server. You can pass a file name to persist data on the disk. - -```js -import { Server } from "@hocuspocus/server"; -import { SQLite } from "@hocuspocus/extension-sqlite"; - -const server = Server.configure({ - extensions: [ - new SQLite({ - database: "db.sqlite", - }), - ], -}); - -server.listen(); -``` - -## Throttle - -This extension throttles connection attempts and bans ip-addresses if it crosses the configured threshold. - -Make sure to register it **before** any other extensions! - -### Installation - -Install the Throttle package with: - -```bash -npm install @hocuspocus/extension-throttle -``` - -### Configuration - -```js -import { Server } from "@hocuspocus/server"; -import { Throttle } from "@hocuspocus/extension-throttle"; - -const server = Server.configure({ - extensions: [ - new Throttle({ - // [optional] allows up to 15 connection attempts per ip address per minute. - // set to null or false to disable throttling, defaults to 15 - throttle: 15, - - // [optional] bans ip addresses for 5 minutes after reaching the threshold - // defaults to 5 - banTime: 5, - }), - ], -}); - -server.listen(); -``` - -## Webhook - -The webhook extension allows you to connect Hocuspocus to your existing application by triggering webhooks on certain events. - -### Installation - -Install the Webhook package with: - -```bash -npm install @hocuspocus/extension-webhook -``` - -### Configuration - -```js -import { Server } from "@hocuspocus/server"; -import { Webhook, Events } from "@hocuspocus/extension-webhook"; -import { TiptapTransformer } from "@hocuspocus/transformer"; - -const server = Server.configure({ - extensions: [ - new Webhook({ - // [required] url of your application - url: "https://example.com/api/hocuspocus", - - // [required] a random string that will be used to verify the request signature - secret: "459824aaffa928e05f5b1caec411ae5f", - - // [required] a transformer for your document - transformer: TiptapTransformer, - - // [optional] array of events that will trigger a webhook - // defaults to [ Events.onChange ] - events: [Events.onConnect, Events.onCreate, Events.onChange, Events.onDisconnect], - - // [optional] time in ms the change event should be debounced, - // defaults to 2000 - debounce: 2000, - - // [optional] time in ms after that the webhook will be sent - // regardless of the configured debouncing, defaults to 10000 - debounceMaxWait: 10000, - }), - ], -}); - -server.listen(); -``` - -### How it works - -The webhook extension listens on up to four configurable events/hooks that will trigger a POST request to the configured url. - -#### onConnect - -When a new user connects to the server, the onConnect webhook will be triggered with the following payload: - -```json -{ - "event": "connect", - "payload": { - "documentName": "example-document", - "requestHeaders": { - "Example-Header": "Example" - }, - "requestParameters": { - "example": "12345" - } - } -} -``` - -You can respond with a JSON payload that will be set as context throughout the rest of the application. For example: - -```js -// authorize the user by the request parameters or headers -if (payload.requestParameters?.get("token") !== "secret-api-token") { - response.writeHead(403, "unathorized"); - return response.end(); -} - -// return context if authorized -response.writeHead(200, { "Content-Type": "application/json" }); -response.end( - JSON.stringify({ - user: { - id: 1, - name: "Jane Doe", - }, - }) -); -``` - -#### onCreate - -When a new document is created the onCreate webhook will be triggered with the following payload: - -```json -{ - "event": "create", - "payload": { - "documentName": "example-document" - } -} -``` - -You can use this to import a document into Hocuspocus. The webhook extension will first load the document from the primary storage and only import it if it doesn't already exist in there. - -Just respond with all the single documents keyed by their field name. For example: - -```js -response.writeHead(200, { "Content-Type": "application/json" }); -response.end( - JSON.stringify({ - // Document for the "secondary" field - secondary: {}, - // Document for the "default" field - default: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "What is love?", - }, - ], - }, - ], - }, - }) -); -``` - -#### onChange - -When a document is changed the onChange webhook will be triggered with the following payload including the context you set before: - -```json -{ - "event": "change", - "payload": { - "documentName": "example-document", - "document": { - "another-field-name": {}, - "field-name": { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "What is love?" - } - ] - } - ] - } - }, - "context": { - "user_id": 1, - "name": "Jane Doe" - } - } -} -``` - -Because this happens on every keystroke up to multiple times a second, the webhook is debounced by default. You can configure this (or shut it off entirely) with the `debounce` and `debounceMaxWait` configuration options. - -#### onDisconnect - -When a user disconnects the onDisconnect webhook will be triggered with the following payload: - -```json -{ - "event": "disconnect", - "payload": { - "documentName": "example-document", - "context": { - "user_id": 1, - "name": "Jane Doe" - } - } -} -``` - -### Transformation - -The Y-Doc must be serialized into something readable by your application and when importing a document it must be converted into a Y-Doc respectively. - -Because Hocuspocus doesn't know how your data is structured, you need to pass a transformer to the Webhook extension. You can use one of the transformers from the `@hocuspocus/transformer` package. Make sure to configure them properly. In this example we used the TiptapTransformer that needs the list of extensions: - -```js -import { Server } from "@hocuspocus/server"; -import { Webhook } from "@hocuspocus/extension-webhook"; -import { TiptapTransformer } from "@hocuspocus/extension-transformer"; -import Document from "@tiptap/extension-document"; -import Paragraph from "@tiptap/extension-paragraph"; -import Text from "@tiptap/extension-text"; - -const server = Server.configure({ - extensions: [ - new Webhook({ - url: "https://example.com/api/webhook", - secret: "459824aaffa928e05f5b1caec411ae5f", - - transformer: TiptapTransformer.extensions([Document, Paragraph, Text]), - }), - ], -}); - -server.listen(); -``` - -Alternatively you can write your own implementation by simply passing functions that convert a Y-Doc to your representation and vice versa: - -```js -import { Server } from "@hocuspocus/server"; -import { Webhook } from "@hocuspocus/extension-webhook"; -import { Doc } from "yjs"; - -const server = Server.configure({ - extensions: [ - new Webhook({ - url: "https://example.com/api/webhook", - secret: "459824aaffa928e05f5b1caec411ae5f", - - transformer: { - toYdoc(document: any, fieldName: string): Doc { - // convert the given document (from your api) to a ydoc using the provided fieldName - return new Doc(); - }, - fromYdoc(document: Doc): any { - // convert the ydoc to your representation - return document.toJSON(); - }, - }, - }), - ], -}); - -server.listen(); -``` - -### Verify Request Signature - -On your application server you should verify the signature coming from the webhook extension to secure the route. - -The extension sends POST requests, and the signature is stored in the `X-Hocuspocus-Signature-256` header containing a message authentication code created with sha256. - -Here are some examples how you could do that in different languages: - -**PHP** - -```php -use Symfony\Component\HttpFoundation\Request; - -function verifySignature(Request $request) { - $secret = '459824aaffa928e05f5b1caec411ae5f'; - - if (($signature = $request->headers->get('X-Hocuspocus-Signature-256')) == null) { - throw new Exception('Header not set'); - } - - $parts = explode('=', $signature); - - if (count($parts) != 2) { - throw new Exception('Invalid signature format'); - } - - $digest = hash_hmac('sha256', $request->getContent(), $secret); - - return hash_equals($digest, $parts[1]); -} - -``` - -**JavaScript** - -```js -import { IncomingMessage } from 'http' - -const secret = '459824aaffa928e05f5b1caec411ae5f' - -const verifySignature = (request: IncomingMessage): boolean => { - const signature = Buffer.from(request.headers['x-hocuspocus-signature-256'] as string) - - const hmac = createHmac('sha256', secret) - const digest = Buffer.from(`sha256=${hmac.update(body).digest('hex')}`) - - return signature.length !== digest.length || timingSafeEqual(digest, signature) -} -``` +| [Database](/server/extensions/database) | A generic database driver that is easily adjustable to work with any database. | +| [Logger](/server/extensions/logger) | Add logging to Hocuspocus. | +| [Redis](/server/extensions/redis) | Scale Hocuspocus horizontally with Redis. | +| [SQLite](/server/extensions/sqlite) | Persist documents to SQLite. | +| [Throttle](/server/extensions/throttle) | Throttle connections by ips. | +| [Webhook](/server/extensions/webhook) | Send document changes via webhook to your API. | diff --git a/docs/server/extensions/database.md b/docs/server/extensions/database.md new file mode 100644 index 000000000..1ad77b5bf --- /dev/null +++ b/docs/server/extensions/database.md @@ -0,0 +1,81 @@ +# Extension Database + +Store your data in whatever data store you already have with the generic database extension. +It takes a Promise to fetch data and another Promise to store the data, that’s all. Hocuspocus will handle the rest. + +## Installation + +Install the database extension like this: + +```bash +npm install @hocuspocus/extension-database +``` + +## Configuration + +**fetch** + +Expects an async function (or Promise) which returns a Y.js compatible Uint8Array (or null). +Make sure to return the same Uint8Array that was saved in store(), and do not create a new Ydoc, +as doing so would lead to a new history (and duplicated content). + +If you want to initially create a Ydoc based off raw text/json, you can do so here using a transformer of your choice +(e.g. `TiptapTransformer.toYdoc`, or `ProsemirrorTransformer.toYdoc`) + +**store** + +Expects an async function (or Promise) which persists the Y.js binary data somewhere. + +## Usage + +The following example uses SQLite to store and retrieve data. You can replace that part with whatever data store you +have. As long as you return a Promise you can store data with PostgreSQL, MySQL, MongoDB, S3 … If you actually want to +use SQLite, you can have a look at the [SQLite extension](/server/extensions#Sqlite). + +```js +import { Server } from "@hocuspocus/server"; +import { Database } from "@hocuspocus/extension-database"; +import sqlite3 from "sqlite3"; + +const server = new Server({ + extensions: [ + new Database({ + // Return a Promise to retrieve data … + fetch: async ({ documentName }) => { + return new Promise((resolve, reject) => { + this.db?.get( + ` + SELECT data FROM "documents" WHERE name = $name ORDER BY rowid DESC + `, + { + $name: documentName, + }, + (error, row) => { + if (error) { + reject(error); + } + + resolve(row?.data); + } + ); + }); + }, + // … and a Promise to store data: + store: async ({ documentName, state }) => { + this.db?.run( + ` + INSERT INTO "documents" ("name", "data") VALUES ($name, $data) + ON CONFLICT(name) DO UPDATE SET data = $data + `, + { + $name: documentName, + $data: state, + } + ); + }, + }), + ], +}); + +server.listen(); +``` diff --git a/docs/server/extensions/logger.md b/docs/server/extensions/logger.md new file mode 100644 index 000000000..b8802df9b --- /dev/null +++ b/docs/server/extensions/logger.md @@ -0,0 +1,78 @@ +# Extension Logger + +Hocuspocus doesn’t log anything. Thanks to this simple extension it will. + +## Installation + +Install the Logger package with: + +```bash +npm install @hocuspocus/extension-logger +``` + +## Configuration + +**Instance name** + +You can prepend all logging messages with a configured string. + +```js +import { Server } from "@hocuspocus/server"; +import { Logger } from "@hocuspocus/extension-logger"; + +const server = new Server({ + name: "hocuspocus-fra1-01", + extensions: [new Logger()], +}); + +server.listen(); +``` + +**Disable messages** + +You can disable logging for specific messages. + +```js +import { Server } from "@hocuspocus/server"; +import { Logger } from "@hocuspocus/extension-logger"; + +const server = new Server({ + extensions: [ + new Logger({ + onLoadDocument: false, + onChange: false, + onConnect: false, + onDisconnect: false, + onUpgrade: false, + onRequest: false, + onListen: false, + onDestroy: false, + onConfigure: false, + }), + ], +}); + +server.listen(); +``` + +**Custom logger** + +You can even pass a custom function to log messages. + +```js +import { Server } from "@hocuspocus/server"; +import { Logger } from "@hocuspocus/extension-logger"; + +const server = new Server({ + extensions: [ + new Logger({ + log: (message) => { + // do something custom here + console.log(message); + }, + }), + ], +}); + +server.listen(); +``` diff --git a/docs/server/extensions/redis.md b/docs/server/extensions/redis.md new file mode 100644 index 000000000..35e0d4046 --- /dev/null +++ b/docs/server/extensions/redis.md @@ -0,0 +1,88 @@ +# Extension Redis + +Hocuspocus can be scaled horizontally using the Redis extension. You can spawn multiple instances of the server behind a +load balancer and sync changes and awareness states through Redis. Hocuspocus will propagate all received updates to all other instances +using Redis and thus forward updates to all clients of all Hocuspocus instances. + +The Redis extension does not persist data; it only syncs data between instances. Use the [Database](/server/extensions/database) extension to store your documents. + +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 +servers, you should not connect them via Redis. + +Thanks to [@tommoor](https://github.com/tommoor) for writing the initial implementation of that extension. + +## Installation + +Install the Redis extension with: + +```bash +npm install @hocuspocus/extension-redis +``` + +## Configuration + +For a full documentation on all available Redis and Redis cluster options, check out the +[ioredis API docs](https://github.com/luin/ioredis/blob/master/API.md). + +```js +import { Server } from "@hocuspocus/server"; +import { Redis } from "@hocuspocus/extension-redis"; + +const server = new Server({ + extensions: [ + new Redis({ + // [required] Hostname of your Redis instance + host: "127.0.0.1", + + // [required] Port of your Redis instance + port: 6379, + }), + ], +}); + +server.listen(); +``` + +## Usage + +The Redis extension works well with the database extension. Once an instance stores a document, it’s blocked for all +other instances to avoid write conflicts. + +```js +import { Server } from "@hocuspocus/server"; +import { Logger } from "@hocuspocus/extension-logger"; +import { Redis } from "@hocuspocus/extension-redis"; +import { SQLite } from "@hocuspocus/extension-sqlite"; + +// Server 1 +const server = new Server({ + name: "server-1", // make sure to use unique server names + port: 1234, + extensions: [ + new Logger(), + new Redis({ + host: "127.0.0.1", // make sure to use the same Redis instance :-) + port: 6379, + }), + new SQLite(), + ], +}); + +server.listen(); + +// Server 2 +const anotherServer = new Server({ + name: "server-2", + port: 1235, + extensions: [ + new Logger(), + new Redis({ + host: "127.0.0.1", + port: 6379, + }), + new SQLite(), + ], +}); + +anotherServer.listen(); +``` diff --git a/docs/server/extensions/sqlite.md b/docs/server/extensions/sqlite.md new file mode 100644 index 000000000..0584e59fd --- /dev/null +++ b/docs/server/extensions/sqlite.md @@ -0,0 +1,68 @@ +# Extension SQLite + +## Introduction + +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 +extension is for. + +## Installation + +Install the SQLite extension like this: + +```bash +npm install @hocuspocus/extension-sqlite +``` + +## Configuration + +**database** + +Valid values are filenames, ":memory:" for an anonymous in-memory database and an empty +string for an anonymous disk-based database. Anonymous databases are not persisted and +when closing the database handle, their contents are lost. + +https://github.com/mapbox/node-sqlite3/wiki/API#new-sqlite3databasefilename-mode-callback + +Default: `:memory:` + +**schema** + +The SQLite schema that’s created for you. + +Default: + +```sql +CREATE TABLE IF NOT EXISTS "documents" ( + "name" varchar(255) NOT NULL, + "data" blob NOT NULL, + UNIQUE(name) +) +``` + +**fetch** + +An async function to retrieve data from SQLite. If you change the schema, you probably want to override the query. + +**store** + +An async function to store data in SQLite. If you change the schema, you probably want to override the query. + +**Usage** + +By default, data is just “stored” in `:memory:`, so it’s wiped when you stop the server. You can pass a file name to +persist data on the disk. + +```js +import { Server } from "@hocuspocus/server"; +import { SQLite } from "@hocuspocus/extension-sqlite"; + +const server = new Server({ + extensions: [ + new SQLite({ + database: "db.sqlite", + }), + ], +}); + +server.listen(); +``` diff --git a/docs/server/extensions/throttle.md b/docs/server/extensions/throttle.md new file mode 100644 index 000000000..8e824884d --- /dev/null +++ b/docs/server/extensions/throttle.md @@ -0,0 +1,36 @@ +# Extension Throttle + +This extension throttles connection attempts and bans ip-addresses if it crosses the configured threshold. + +Make sure to register it **before** any other extensions! + +## Installation + +Install the Throttle package with: + +```bash +npm install @hocuspocus/extension-throttle +``` + +## Configuration + +```js +import { Server } from "@hocuspocus/server"; +import { Throttle } from "@hocuspocus/extension-throttle"; + +const server = new Server({ + extensions: [ + new Throttle({ + // [optional] allows up to 15 connection attempts per ip address per minute. + // set to null or false to disable throttling, defaults to 15 + throttle: 15, + + // [optional] bans ip addresses for 5 minutes after reaching the threshold + // defaults to 5 + banTime: 5, + }), + ], +}); + +server.listen(); +``` diff --git a/docs/server/extensions/webhook.md b/docs/server/extensions/webhook.md new file mode 100644 index 000000000..ff0e50313 --- /dev/null +++ b/docs/server/extensions/webhook.md @@ -0,0 +1,305 @@ +# Extension Webhook + +The webhook extension allows you to connect Hocuspocus to your existing application by triggering webhooks on certain +events. + +## Installation + +Install the Webhook package with: + +```bash +npm install @hocuspocus/extension-webhook +``` + +## Configuration + +```js +import { Server } from "@hocuspocus/server"; +import { Webhook, Events } from "@hocuspocus/extension-webhook"; +import { TiptapTransformer } from "@hocuspocus/transformer"; + +const server = new Server({ + extensions: [ + new Webhook({ + // [required] url of your application + url: "https://example.com/api/hocuspocus", + + // [required] a random string that will be used to verify the request signature + secret: "459824aaffa928e05f5b1caec411ae5f", + + // [required] a transformer for your document + transformer: TiptapTransformer, + + // [optional] array of events that will trigger a webhook + // defaults to [ Events.onChange ] + events: [Events.onConnect, Events.onCreate, Events.onChange, Events.onDisconnect], + + // [optional] time in ms the change event should be debounced, + // defaults to 2000 + debounce: 2000, + + // [optional] time in ms after that the webhook will be sent + // regardless of the configured debouncing, defaults to 10000 + debounceMaxWait: 10000, + }), + ], +}); + +server.listen(); +``` + +## How it works + +The webhook extension listens on up to four configurable events/hooks that will trigger a POST request to the +configured url. + +### onConnect + +When a new user connects to the server, the onConnect webhook will be triggered with the following payload: + +```json +{ + "event": "connect", + "payload": { + "documentName": "example-document", + "requestHeaders": { + "Example-Header": "Example" + }, + "requestParameters": { + "example": "12345" + } + } +} +``` + +You can respond with a JSON payload that will be set as context throughout the rest of the application. For example: + +```js +// authorize the user by the request parameters or headers +if (payload.requestParameters?.get("token") !== "secret-api-token") { + response.writeHead(403, "unathorized"); + return response.end(); +} + +// return context if authorized +response.writeHead(200, { "Content-Type": "application/json" }); +response.end( + JSON.stringify({ + user: { + id: 1, + name: "Jane Doe", + }, + }) +); +``` + +### onCreate + +When a new document is created the onCreate webhook will be triggered with the following payload: + +```json +{ + "event": "create", + "payload": { + "documentName": "example-document" + } +} +``` + +You can use this to import a document into Hocuspocus. The webhook extension will first load the document from the +primary storage and only import it if it doesn't already exist in there. + +Just respond with all the single documents keyed by their field name. For example: + +```js +response.writeHead(200, { "Content-Type": "application/json" }); +response.end( + JSON.stringify({ + // Document for the "secondary" field + secondary: {}, + // Document for the "default" field + default: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "What is love?", + }, + ], + }, + ], + }, + }) +); +``` + +### onChange + +When a document is changed the onChange webhook will be triggered with the following payload including the context you +set before: + +```json +{ + "event": "change", + "payload": { + "documentName": "example-document", + "document": { + "another-field-name": {}, + "field-name": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "What is love?" + } + ] + } + ] + } + }, + "context": { + "user_id": 1, + "name": "Jane Doe" + } + } +} +``` + +Because this happens on every keystroke up to multiple times a second, the webhook is debounced by default. You can +configure this (or shut it off entirely) with the `debounce` and `debounceMaxWait` configuration options. + +### onDisconnect + +When a user disconnects the onDisconnect webhook will be triggered with the following payload: + +```json +{ + "event": "disconnect", + "payload": { + "documentName": "example-document", + "context": { + "user_id": 1, + "name": "Jane Doe" + } + } +} +``` + +### Transformation + +The Y-Doc must be serialized into something readable by your application and when importing a document it must be +converted into a Y-Doc respectively. + +Because Hocuspocus doesn't know how your data is structured, you need to pass a transformer to the Webhook extension. +You can use one of the transformers from the `@hocuspocus/transformer` package. Make sure to configure them properly. +In this example we used the TiptapTransformer that needs the list of extensions: + +```js +import { Server } from "@hocuspocus/server"; +import { Webhook } from "@hocuspocus/extension-webhook"; +import { TiptapTransformer } from "@hocuspocus/extension-transformer"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; + +const server = new Server({ + extensions: [ + new Webhook({ + url: "https://example.com/api/webhook", + secret: "459824aaffa928e05f5b1caec411ae5f", + + transformer: TiptapTransformer.extensions([Document, Paragraph, Text]), + }), + ], +}); + +server.listen(); +``` + +Alternatively you can write your own implementation by simply passing functions that convert a Y-Doc to your +representation and vice versa: + +```js +import { Server } from "@hocuspocus/server"; +import { Webhook } from "@hocuspocus/extension-webhook"; +import { Doc } from "yjs"; + +const server = new Server({ + extensions: [ + new Webhook({ + url: "https://example.com/api/webhook", + secret: "459824aaffa928e05f5b1caec411ae5f", + + transformer: { + toYdoc(document: any, fieldName: string): Doc { + // convert the given document (from your api) to a ydoc using the provided fieldName + return new Doc(); + }, + fromYdoc(document: Doc): any { + // convert the ydoc to your representation + return document.toJSON(); + }, + }, + }), + ], +}); + +server.listen(); +``` + +### Verify Request Signature + +On your application server you should verify the signature coming from the webhook extension to secure the route. + +The extension sends POST requests, and the signature is stored in the `X-Hocuspocus-Signature-256` header containing a +message authentication code created with sha256. + +Here are some examples how you could do that in different languages: + +**PHP** + +```php +use Symfony\Component\HttpFoundation\Request; + +function verifySignature(Request $request) { + $secret = '459824aaffa928e05f5b1caec411ae5f'; + + if (($signature = $request->headers->get('X-Hocuspocus-Signature-256')) == null) { + throw new Exception('Header not set'); + } + + $parts = explode('=', $signature); + + if (count($parts) != 2) { + throw new Exception('Invalid signature format'); + } + + $digest = hash_hmac('sha256', $request->getContent(), $secret); + + return hash_equals($digest, $parts[1]); +} + +``` + +**JavaScript** + +```js +import { IncomingMessage } from 'http' + +const secret = '459824aaffa928e05f5b1caec411ae5f' + +const verifySignature = (request: IncomingMessage): boolean => { + const signature = Buffer.from(request.headers['x-hocuspocus-signature-256'] as string) + + const hmac = createHmac('sha256', secret) + const digest = Buffer.from(`sha256=${hmac.update(body).digest('hex')}`) + + return signature.length !== digest.length || timingSafeEqual(digest, signature) +} +``` diff --git a/docs/server/hooks.md b/docs/server/hooks.md index 9f60f181f..e67905a5b 100644 --- a/docs/server/hooks.md +++ b/docs/server/hooks.md @@ -55,7 +55,7 @@ By way of illustration, if a user isn’t allowed to connect: Just throw an erro ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onAuthenticate({ documentName, token }) { // Could be an API call, DB query or whatever … // The endpoint should return 200 OK in case the user is authenticated, and an http error @@ -116,7 +116,7 @@ import { writeFile } from "fs"; let debounced; -const server = Server.configure({ +const server = new Server({ beforeHandleMessage(data) { if (data.context.tokenExpiresAt <= new Date()) { const error: CloseEvent = { @@ -140,7 +140,7 @@ The `connected` hooks are called after a new connection has been successfully es ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async connected() { console.log("connections:", server.getConnectionsCount()); }, @@ -184,7 +184,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onAuthenticate(data) { const { token } = data; @@ -220,7 +220,7 @@ Once The `onAuthenticate` hooks are configured, the server will wait for the aut ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onConnect({ connection }) { connection.requiresAuthentication = false; }, @@ -322,7 +322,7 @@ import { writeFile } from "fs"; let debounced; -const server = Server.configure({ +const server = new Server({ async onChange(data) { const save = () => { // Convert the y-doc to something you can actually use in your views. @@ -381,7 +381,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onConfigure(data) { // Output some information console.log(`Server was configured!`); @@ -422,7 +422,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onConnect(data) { // Output some information console.log(`New websocket connection`); @@ -451,7 +451,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onDestroy(data) { // Output some information console.log(`Server was shut down!`); @@ -493,7 +493,7 @@ Context contains the data provided in former `onConnect` hooks. ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onDisconnect(data) { // Output some information console.log(`"${data.context.user.name}" has disconnected.`); @@ -522,7 +522,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async onListen(data) { // Output some information console.log(`Server is listening on port "${data.port}"!`); @@ -545,7 +545,6 @@ You can create a Y.js document from your existing data, for example JSON. You sh To do this, you can use the Transformer package. For Tiptap-compatible JSON it would look like this: ```js -import { Server } from "@hocuspocus/server"; import { TiptapTransformer } from "@hocuspocus/transformer"; import Document from "@tiptap/extension-document"; import Paragraph from "@tiptap/extension-paragraph"; @@ -568,7 +567,7 @@ However, we expect you to return a Y.js document from the `onLoadDocument` hook, ```js import { Server } from '@hocuspocus/server' -const server = Server.configure({ +const server = new Server({ async onLoadDocument(data) { // fetch the Y.js document from somewhere const ydoc = … @@ -582,9 +581,9 @@ server.listen() **Fetch your Y.js documents (recommended)** -There are multiple ways to store your Y.js documents (and their history) wherever you like. Basically, you should use the `onStoreDocument` hook, which is debounced and executed every few seconds for changed documents. It gives you the current Y.js document and it’s up to you to store that somewhere. No worries, we provide some convient ways for you. +There are multiple ways to store your Y.js documents (and their history) wherever you like. Basically, you should use the `onStoreDocument` hook, which is debounced and executed every few seconds for changed documents. It gives you the current Y.js document, and it’s up to you to store that somewhere. No worries, we provide some convenient ways for you. -If you just want to to get it working, have a look at the [`SQLite`](/server/extensions#Sqlite) extension for local development, and the generic [`Database`](/server/extensions#Database) extension for a convenient way to fetch and store documents. +If you just want to get it working, have a look at the [`SQLite`](/server/extensions#Sqlite) extension for local development, and the generic [`Database`](/server/extensions#Database) extension for a convenient way to fetch and store documents. **Hook payload** @@ -658,7 +657,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ onRequest(data) { return new Promise((resolve, reject) => { const { request, response } = data; @@ -741,7 +740,7 @@ const data = { import { Server } from "@hocuspocus/server"; import WebSocket, { WebSocketServer } from "ws"; -const server = Server.configure({ +const server = new Server({ onUpgrade(data) { return new Promise((resolve, reject) => { const { request, socket, head } = data; @@ -799,7 +798,7 @@ const data = { ```js import { Server } from '@hocuspocus/server' -const server = Server.configure({ +const server = new Server({ async onStateless({ payload, document, connection }) { // Output some information console.log(`Server has received a stateless message "${payload}"!`) @@ -836,7 +835,7 @@ const data = { ```js import { Server } from '@hocuspocus/server' -const server = Server.configure({ +const server = new Server({ beforeBroadcastStateless({ payload }) { console.log(`Server will broadcast a stateless message: "${payload}"!`) }, @@ -848,7 +847,7 @@ server.listen() ### afterUnloadDocument The `afterUnloadDocument` hooks are called after a document was closed on the server. You can no -longer access the document at this point as it has been destroyed but you may notify anything +longer access the document at this point as it has been destroyed, but you may notify anything that was subscribed to the document. Note: `afterUnloadDocument` may be called even if `afterLoadDocument` never was for a given document @@ -870,7 +869,7 @@ const data = { ```js import { Server } from "@hocuspocus/server"; -const server = Server.configure({ +const server = new Server({ async afterUnloadDocument(data) { // Output some information console.log(`Document ${data.documentName} was closed`); diff --git a/docs/server/methods.md b/docs/server/methods.md index e3bd24560..147081d0d 100644 --- a/docs/server/methods.md +++ b/docs/server/methods.md @@ -4,24 +4,18 @@ tableOfContents: true # Methods -| Method | Description | -|------------------------------------------------|---------------------------------------------------| -| `listen(portOrCallback, callback)` | Start the server. | -| `configure(configuration)` | Pass custom settings. | -| `handleConnection(incoming, request, context)` | Bind the server to an existing server instance. | -| `getDocumentsCount()` | Get the total number of active documents | -| `getConnectionsCount()` | Get the total number of active connections | -| `closeConnections(documentName?)` | Close all connections, or to a specific document. | -| `destroy()` | Stop the server. | -| `openDirectConnection(documentName, context)` | Creates a local connection to a document. | +## Server -## Usage +| Method | Description | +|--------------------------|---------------------------------------------------| +| `listen(port, callback)` | Start the server. | +| `destroy()` | Stop the server. | ```js -import {Server} from "@hocuspocus/server"; +import { Server } from "@hocuspocus/server"; // Configure … -const server = Server.configure({ +const server = new Server({ port: 1234, }); @@ -31,3 +25,14 @@ server.listen(); // Destroy … server.destroy(); ``` + +## Hocuspocus + +| Method | Description | +|------------------------------------------------|---------------------------------------------------| +| `configure(configuration)` | Pass custom settings. | +| `handleConnection(incoming, request, context)` | Bind the server to an existing server instance. | +| `getDocumentsCount()` | Get the total number of active documents | +| `getConnectionsCount()` | Get the total number of active connections | +| `closeConnections(documentName?)` | Close all connections, or to a specific document. | +| `openDirectConnection(documentName, context)` | Creates a local connection to a document. | diff --git a/docs/server/usage.md b/docs/server/usage.md new file mode 100644 index 000000000..fd8c59006 --- /dev/null +++ b/docs/server/usage.md @@ -0,0 +1,54 @@ +--- +tableOfContents: true +--- + +# Usage + +There are two ways on how you can use hocuspocus. Either with the built-in server. Or with another framework, for +example with [Express](/server/examples#express). + +## Hocuspocus Server + +Using the built-in server make sure to import `Server` from `@hocuspocus/server`. You configure the server as described +under [configuration](/server/configuration). The built-in server spins up a webserver and a websocket server. + +```js +import { Server } from "@hocuspocus/server"; + +// Configure the server +const server = new Server({ + port: 1234, +}); + +// Listen … +server.listen(); + +// Destroy … +server.destroy(); +``` + +You can access the instance of hocuspocus through the webserver to call it's [methods](/server/methods). + +```js +// … + +server.hocuspocus.getDocumentsCount(); +``` + +## Hocuspocus + +As mentioned earlier, you can use hocuspocus without the built-in server. Make sure to import `Hocuspocus` from the +`@hocuspocus/server` package. + +```js +import { Hocuspocus } from "@hocuspocus/server"; + +// Configure hocuspocus +const hocuspocus = new Hocuspocus({ + name: "hocuspocus-fra1-01", +}) + +// … +``` + +Check out the [examples](/server/examples) to learn more. diff --git a/docs/upgrade.md b/docs/upgrade.md new file mode 100644 index 000000000..90c9bf7c0 --- /dev/null +++ b/docs/upgrade.md @@ -0,0 +1,100 @@ +# Upgrade Guide + +## Upgrading to 3.0 from 2.x + +With the upgrade to the new version, the initialization of hocuspocus has changed. As described on the [usage](/server/usage) +side, there are two ways on how you can use hocuspocus. With the built-in server. Or like a library with other +frameworks (like [express](/server/examples#express)). To make things simpler and enable more features in the future, +we separated classes and put the server into its own class. + +### Usage with .configure() + +It is no longer possible to use hocuspocus with `.configure()`. You always have to create a new instance by yourself. + +**Old Way** +```js +import { Server } from "@hocuspocus/server"; + +const server = Server.configure({ + port: 1234, +}); + +server.listen(); +``` + +**New Way** +```js +import { Server } from "@hocuspocus/server"; + +const server = new Server({ + port: 1234, +}); + +server.listen(); +``` + +Notice, that the import has not changed. The configuration options stay the same here. + +### Usage of Hocuspocus without built-in server + +If you have used Hocuspocus without the built-in server before, you have to update your setup as well. + +**Old Way** +```js +import { Server } from "@hocuspocus/server"; + +const server = Server.configure({ + // ... +}); +``` + +**New Way** +```js +import { Hocuspocus } from "@hocuspocus/server"; + +const hocuspocus = new Hocuspocus({ + // ... +}); + +// You still use handleConnection as you did before. +hocuspocus.handleConnection(...); +``` + +Notice the change of the import from `Server` to `Hocuspocus` as well as the initialization with `new Hocuspocus()`. +See [examples](/server/examples) for more on that. + +### Change of the servers listen signature + +The `.listen()` function of the server was quite versatile. We simplified the signature of it while you can still reach +the same behavior as before. + +**Old Signature** +```js +async listen( + portOrCallback: number | ((data: onListenPayload) => Promise) | null = null, + callback: any = null, +): Promise +``` + +**New Signature** +```js +async listen(port?: number, callback: any = null): Promise +``` + +The listen method still returns a Promise which will be resolved to Hocuspocus, if nothing fails. + +Both the callbacks you could provide in the old version were added to the `onListen` hook. This is still the case with +the callback on the new version. But you can't provide just a callback on the first parameter anymore. If you just want +to add a callback you also still can add it within the configuration of the server. + +```js +import { Server } from "@hocuspocus/server"; + +const server = new Server({ + async onListen(data) { + console.log(`Server is listening on port "${data.port}"!`); + }, +}); + +server.listen() +``` diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index 6c577220f..eb568b90e 100755 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -60,7 +60,7 @@ export const getConfiguredSQLiteExtension = () => { return undefined } -const server = Server.configure({ +const server = new Server({ port: parseInt(cli.flags.port, 10), extensions: [ new Logger(), diff --git a/packages/provider/src/TiptapCollabProvider.ts b/packages/provider/src/TiptapCollabProvider.ts index 08c5fd25e..1758aad08 100644 --- a/packages/provider/src/TiptapCollabProvider.ts +++ b/packages/provider/src/TiptapCollabProvider.ts @@ -15,7 +15,7 @@ export type TiptapCollabProviderConfiguration = export interface AdditionalTiptapCollabProviderConfiguration { /** - * A Hocuspocus Cloud App ID, get one here: https://collab.tiptap.dev + * A Hocuspocus Cloud App ID, get one here: https://cloud.tiptap.dev */ appId?: string, diff --git a/packages/provider/src/TiptapCollabProviderWebsocket.ts b/packages/provider/src/TiptapCollabProviderWebsocket.ts index b771d083f..668ed9755 100644 --- a/packages/provider/src/TiptapCollabProviderWebsocket.ts +++ b/packages/provider/src/TiptapCollabProviderWebsocket.ts @@ -9,7 +9,7 @@ export type TiptapCollabProviderWebsocketConfiguration = export interface AdditionalTiptapCollabProviderWebsocketConfiguration { /** - * A Hocuspocus Cloud App ID, get one here: https://collab.tiptap.dev + * A Hocuspocus Cloud App ID, get one here: https://cloud.tiptap.dev */ appId: string, } diff --git a/packages/server/src/ClientConnection.ts b/packages/server/src/ClientConnection.ts index d61a73e39..c2228f83b 100644 --- a/packages/server/src/ClientConnection.ts +++ b/packages/server/src/ClientConnection.ts @@ -6,7 +6,6 @@ import { import * as decoding from 'lib0/decoding' import { v4 as uuid } from 'uuid' import WebSocket from 'ws' - import Connection from './Connection.js' import { Debugger } from './Debugger.js' import Document from './Document.js' @@ -326,6 +325,7 @@ export class ClientConnection { this.hookPayloads[documentName] = hookPayload } + this.handleQueueingMessage(data) if (isFirst) { diff --git a/packages/server/src/Hocuspocus.ts b/packages/server/src/Hocuspocus.ts index 646a2310a..26e806325 100644 --- a/packages/server/src/Hocuspocus.ts +++ b/packages/server/src/Hocuspocus.ts @@ -1,14 +1,12 @@ import { IncomingMessage } from 'http' -import { ListenOptions } from 'net' import { ResetConnection, awarenessStatesToArray, } from '@hocuspocus/common' -import kleur from 'kleur' import { v4 as uuid } from 'uuid' -import WebSocket, { AddressInfo } from 'ws' +import WebSocket from 'ws' import { Doc, applyUpdate, encodeStateAsUpdate } from 'yjs' import meta from '../package.json' assert { type: 'json' } -import { Server as HocuspocusServer } from './Server' +import { Server } from './Server' import { ClientConnection } from './ClientConnection.js' // TODO: would be nice to only have a dependency on ClientConnection, and not on Connection import Connection from './Connection.js' @@ -24,7 +22,6 @@ import { beforeBroadcastStatelessPayload, onChangePayload, onDisconnectPayload, - onListenPayload, onStoreDocumentPayload, } from './types.js' import { getParameters } from './util/getParameters.js' @@ -32,8 +29,6 @@ import { useDebounce } from './util/debounce' export const defaultConfiguration = { name: null, - port: 80, - address: '0.0.0.0', timeout: 30000, debounce: 2000, maxDebounce: 10000, @@ -43,12 +38,8 @@ export const defaultConfiguration = { gcFilter: () => true, }, unloadImmediately: true, - stopOnSignals: true, } -/** - * Hocuspocus Server - */ export class Hocuspocus { configuration: Configuration = { ...defaultConfiguration, @@ -73,7 +64,7 @@ export class Hocuspocus { documents: Map = new Map() - server?: HocuspocusServer + server?: Server debugger = new Debugger() @@ -86,7 +77,7 @@ export class Hocuspocus { } /** - * Configure the server + * Configure Hocuspocus */ configure(configuration: Partial): Hocuspocus { this.configuration = { @@ -146,119 +137,6 @@ export class Hocuspocus { }) } - /** - * Start the server - */ - async listen( - portOrCallback: number | ((data: onListenPayload) => Promise) | null = null, - callback: any = null, - ): Promise { - if (typeof portOrCallback === 'number') { - this.configuration.port = portOrCallback - } - - if (typeof portOrCallback === 'function') { - this.configuration.extensions.push({ - onListen: portOrCallback, - }) - } - - if (typeof callback === 'function') { - this.configuration.extensions.push({ - onListen: callback, - }) - } - - this.server = new HocuspocusServer(this) - - if (this.configuration.stopOnSignals) { - const signalHandler = async () => { - await this.destroy() - process.exit(0) - } - - process.on('SIGINT', signalHandler) - process.on('SIGQUIT', signalHandler) - process.on('SIGTERM', signalHandler) - } - - return new Promise((resolve: Function, reject: Function) => { - this.server?.httpServer.listen({ - port: this.configuration.port, - host: this.configuration.address, - } as ListenOptions, async () => { - if (!this.configuration.quiet && process.env.NODE_ENV !== 'testing') { - this.showStartScreen() - } - - const onListenPayload = { - instance: this, - configuration: this.configuration, - port: this.address.port, - } - - try { - await this.hooks('onListen', onListenPayload) - resolve(this) - } catch (e) { - reject(e) - } - }) - }) - } - - get address(): AddressInfo { - return (this.server?.httpServer?.address() || { - port: this.configuration.port, - address: this.configuration.address, - family: 'IPv4', - }) as AddressInfo - } - - get URL(): string { - return `${this.configuration.address}:${this.address.port}` - } - - get webSocketURL(): string { - return `ws://${this.URL}` - } - - get httpURL(): string { - return `http://${this.URL}` - } - - private showStartScreen() { - const name = this.configuration.name ? ` (${this.configuration.name})` : '' - - console.log() - console.log(` ${kleur.cyan(`Hocuspocus v${meta.version}${name}`)}${kleur.green(' running at:')}`) - console.log() - console.log(` > HTTP: ${kleur.cyan(`${this.httpURL}`)}`) - console.log(` > WebSocket: ${this.webSocketURL}`) - - const extensions = this.configuration?.extensions.map(extension => { - return extension.extensionName ?? extension.constructor?.name - }) - .filter(name => name) - .filter(name => name !== 'Object') - - if (!extensions.length) { - return - } - - console.log() - console.log(' Extensions:') - - extensions - .forEach(name => { - console.log(` - ${name}`) - }) - - console.log() - console.log(` ${kleur.green('Ready.')}`) - console.log() - } - /** * Get the total number of active documents */ @@ -295,38 +173,6 @@ export class Hocuspocus { }) } - /** - * Destroy the server - */ - async destroy(): Promise { - await new Promise(async resolve => { - - this.server?.httpServer?.close() - - try { - - this.configuration.extensions.push({ - async afterUnloadDocument({ instance }) { - if (instance.getDocumentsCount() === 0) resolve('') - }, - }) - - this.server?.webSocketServer?.close() - if (this.getDocumentsCount() === 0) resolve('') - - this.closeConnections() - - } catch (error) { - console.error(error) - } - - this.debugger.flush() - - }) - - await this.hooks('onDestroy', { instance: this }) - } - /** * The `handleConnection` method receives incoming WebSocket connections, * runs all hooks: @@ -599,5 +445,3 @@ export class Hocuspocus { return new DirectConnection(document, this, context) } } - -export const Server = new Hocuspocus() diff --git a/packages/server/src/Server.ts b/packages/server/src/Server.ts index eaf102847..886d03258 100644 --- a/packages/server/src/Server.ts +++ b/packages/server/src/Server.ts @@ -1,8 +1,24 @@ import { createServer, IncomingMessage, Server as HTTPServer, ServerResponse, } from 'http' -import WebSocket, { WebSocketServer } from 'ws' -import { Hocuspocus } from './Hocuspocus' +import { ListenOptions } from 'net' +import WebSocket, { AddressInfo, WebSocketServer } from 'ws' +import kleur from 'kleur' +import meta from '../package.json' assert { type: 'json' } +import { defaultConfiguration, Hocuspocus } from './Hocuspocus' +import { Configuration, onListenPayload } from './types' + +export interface ServerConfiguration extends Configuration { + port?: number, + address?: string, + stopOnSignals?: boolean, +} + +export const defaultServerConfiguration = { + port: 80, + address: '0.0.0.0', + stopOnSignals: true, +} export class Server { httpServer: HTTPServer @@ -11,8 +27,23 @@ export class Server { hocuspocus: Hocuspocus - constructor(hocuspocus: Hocuspocus) { - this.hocuspocus = hocuspocus + configuration: ServerConfiguration = { + ...defaultConfiguration, + ...defaultServerConfiguration, + extensions: [], + } + + constructor(configuration?: Partial) { + if (configuration) { + this.configuration = { + ...this.configuration, + ...configuration, + } + } + + this.hocuspocus = new Hocuspocus(this.configuration) + this.hocuspocus.server = this + this.httpServer = createServer(this.requestHandler) this.webSocketServer = new WebSocketServer({ noServer: true }) @@ -84,4 +115,133 @@ export class Server { } } } + + async listen(port?: number, callback: any = null): Promise { + if (port) { + this.configuration.port = port + } + + if (typeof callback === 'function') { + this.hocuspocus.configuration.extensions.push({ + onListen: callback, + }) + } + + if (this.configuration.stopOnSignals) { + const signalHandler = async () => { + await this.destroy() + process.exit(0) + } + + process.on('SIGINT', signalHandler) + process.on('SIGQUIT', signalHandler) + process.on('SIGTERM', signalHandler) + } + + return new Promise((resolve: Function, reject: Function) => { + this.httpServer.listen({ + port: this.configuration.port, + address: this.configuration.address, + } as ListenOptions, async () => { + if (!this.configuration.quiet && process.env.NODE_ENV !== 'testing') { + this.showStartScreen() + } + + const onListenPayload = { + instance: this.hocuspocus, + configuration: this.configuration, + port: this.address.port, + } as onListenPayload + + try { + await this.hocuspocus.hooks('onListen', onListenPayload) + resolve(this.hocuspocus) + } catch (e) { + reject(e) + } + }) + }) + } + + get address(): AddressInfo { + return (this.httpServer.address() || { + port: this.configuration.port, + address: this.configuration.address, + family: 'IPv4', + }) as AddressInfo + } + + async destroy(): Promise { + await new Promise(async resolve => { + + this.httpServer.close() + + try { + + this.configuration.extensions.push({ + async afterUnloadDocument({ instance }) { + if (instance.getDocumentsCount() === 0) resolve('') + }, + }) + + this.webSocketServer.close() + if (this.hocuspocus.getDocumentsCount() === 0) resolve('') + + this.hocuspocus.closeConnections() + + } catch (error) { + console.error(error) + } + + this.hocuspocus.debugger.flush() + + }) + + await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus }) + } + + get URL(): string { + return `${this.configuration.address}:${this.address.port}` + } + + get webSocketURL(): string { + return `ws://${this.URL}` + } + + get httpURL(): string { + return `http://${this.URL}` + } + + private showStartScreen() { + const name = this.configuration.name ? ` (${this.configuration.name})` : '' + + console.log() + console.log(` ${kleur.cyan(`Hocuspocus v${meta.version}${name}`)}${kleur.green(' running at:')}`) + console.log() + + console.log(` > HTTP: ${kleur.cyan(`${this.httpURL}`)}`) + console.log(` > WebSocket: ${this.webSocketURL}`) + + const extensions = this.configuration?.extensions.map(extension => { + return extension.extensionName ?? extension.constructor?.name + }) + .filter(name => name) + .filter(name => name !== 'Object') + + if (!extensions.length) { + return + } + + console.log() + console.log(' Extensions:') + + extensions + .forEach(name => { + console.log(` - ${name}`) + }) + + console.log() + console.log(` ${kleur.green('Ready.')}`) + console.log() + } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 345357aad..f7de73dc8 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -5,5 +5,6 @@ export * from './Hocuspocus.js' export * from './IncomingMessage.js' export * from './MessageReceiver.js' export * from './OutgoingMessage.js' +export * from './Server.js' export * from './types.js' export * from './util/debounce.js' diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index cd2b663a8..df29de19b 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -98,6 +98,7 @@ export type HookPayloadByName = { afterUnloadDocument: afterUnloadDocumentPayload, onDestroy: onDestroyPayload, } + export interface Configuration extends Extension { /** * A name for the instance, used for logging. @@ -107,14 +108,6 @@ export interface Configuration extends Extension { * A list of hocuspocus extenions. */ extensions: Array, - /** - * The port which the server listens on. - */ - port?: number, - /** - * The address which the server listens on. - */ - address?: string, /** * Defines in which interval the server sends a ping, and closes the connection when no pong is sent back. */ @@ -141,13 +134,6 @@ export interface Configuration extends Extension { */ unloadImmediately: boolean, - /** - * the server will gracefully stop if SIGINT, SIGQUIT or SIGTERM is received. - * - * Set this to false if you don't want that. - */ - stopOnSignals: boolean, - /** * options to pass to the ydoc document */ diff --git a/playground/backend/.gitignore b/playground/backend/.gitignore index 55b0ee4af..cec101913 100644 --- a/playground/backend/.gitignore +++ b/playground/backend/.gitignore @@ -1,2 +1,3 @@ /database /dashboard +/db.sqlite diff --git a/playground/backend/src/default.ts b/playground/backend/src/default.ts index ad5a19f4d..da7eafd9a 100644 --- a/playground/backend/src/default.ts +++ b/playground/backend/src/default.ts @@ -2,7 +2,7 @@ import { Server } from '@hocuspocus/server' import { Logger } from '@hocuspocus/extension-logger' import { SQLite } from '@hocuspocus/extension-sqlite' -const server = Server.configure({ +const server = new Server({ port: 1234, address: '127.0.0.1', name: 'hocuspocus-fra1-01', diff --git a/playground/backend/src/express.ts b/playground/backend/src/express.ts index 09f5d5cd3..081170c1b 100644 --- a/playground/backend/src/express.ts +++ b/playground/backend/src/express.ts @@ -1,9 +1,9 @@ import { Logger } from '@hocuspocus/extension-logger' -import { Server } from '@hocuspocus/server' +import { Hocuspocus } from '@hocuspocus/server' import express from 'express' import expressWebsockets from 'express-ws' -const server = Server.configure({ +const hocuspocus = new Hocuspocus({ extensions: [ new Logger(), ], @@ -17,7 +17,7 @@ app.get('/', (request, response) => { app.ws('/:documentName', (websocket, request: any) => { const context = { user_id: 1234 } - server.handleConnection(websocket, request, context) + hocuspocus.handleConnection(websocket, request, context) }) app.listen(1234, () => console.log('Listening on http://127.0.0.1:1234…')) diff --git a/playground/backend/src/koa.ts b/playground/backend/src/koa.ts index dfb2f9849..32629786f 100644 --- a/playground/backend/src/koa.ts +++ b/playground/backend/src/koa.ts @@ -1,10 +1,10 @@ // @ts-nocheck import Koa from 'koa' import websocket from 'koa-easy-ws' -import { Server } from '@hocuspocus/server' +import { Hocuspocus } from '@hocuspocus/server' import { Logger } from '@hocuspocus/extension-logger' -const server = Server.configure({ +const hocuspocus = new Hocuspocus({ extensions: [ new Logger(), ], @@ -18,7 +18,7 @@ app.use(async (ctx, next) => { const ws = await ctx.ws() const documentName = ctx.request.path.substring(1) - server.handleConnection( + hocuspocus.handleConnection( ws, ctx.request, documentName, diff --git a/playground/backend/src/load-document.ts b/playground/backend/src/load-document.ts index f0fe552a2..ab011df47 100644 --- a/playground/backend/src/load-document.ts +++ b/playground/backend/src/load-document.ts @@ -20,7 +20,7 @@ const getProseMirrorJSON = (text: string) => { } } -const server = Server.configure({ +const server = new Server({ port: 1234, extensions: [ new Logger(), diff --git a/playground/backend/src/redis.ts b/playground/backend/src/redis.ts index 82b65f8b8..21f4fdbd3 100644 --- a/playground/backend/src/redis.ts +++ b/playground/backend/src/redis.ts @@ -1,9 +1,9 @@ -import { Hocuspocus } from '@hocuspocus/server' +import { Server } from '@hocuspocus/server' import { Logger } from '@hocuspocus/extension-logger' import { Redis } from '@hocuspocus/extension-redis' import { SQLite } from '@hocuspocus/extension-sqlite' -const server = new Hocuspocus({ +const server = new Server({ port: 1234, name: 'redis-1', extensions: [ @@ -18,7 +18,7 @@ const server = new Hocuspocus({ server.listen() -const anotherServer = new Hocuspocus({ +const anotherServer = new Server({ port: 1235, name: 'redis-2', extensions: [ diff --git a/playground/backend/src/slow.ts b/playground/backend/src/slow.ts index 58e1d0e67..a7182e506 100644 --- a/playground/backend/src/slow.ts +++ b/playground/backend/src/slow.ts @@ -2,7 +2,7 @@ import { Server } from '@hocuspocus/server' import { Logger } from '@hocuspocus/extension-logger' import { SQLite } from '@hocuspocus/extension-sqlite' -const server = Server.configure({ +const server = new Server({ port: 1234, extensions: [ new Logger(), diff --git a/playground/backend/src/webhook.ts b/playground/backend/src/webhook.ts index 2ac4298e4..9eb35466b 100644 --- a/playground/backend/src/webhook.ts +++ b/playground/backend/src/webhook.ts @@ -10,7 +10,7 @@ import { Events, Webhook } from '@hocuspocus/extension-webhook' /* * Setup server */ -const server = Server.configure({ +const server = new Server({ port: 1234, extensions: [ new Logger(), diff --git a/tests/extension-logger/onListen.ts b/tests/extension-logger/onListen.ts index 80ff9a806..3abcda28a 100644 --- a/tests/extension-logger/onListen.ts +++ b/tests/extension-logger/onListen.ts @@ -33,7 +33,7 @@ test('uses the global instance name', async t => { await new Promise(async resolve => { const spy = sinon.spy(fakeLogger) - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ name: 'FOOBAR123', async onDestroy() { t.is(spy.args[spy.args.length - 1][0].includes('FOOBAR123'), true, 'Expected the Logger to use the configured instance name.') @@ -47,7 +47,7 @@ test('uses the global instance name', async t => { ], }) - await server.destroy() + await hocuspocus.server!.destroy() }) }) @@ -56,7 +56,7 @@ test('doesn’t log anything if all messages are disabled', async t => { await new Promise(async resolve => { const spy = sinon.spy(fakeLogger) - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onDestroy() { t.is(spy.callCount, 0, 'Expected the Logger to not log anything.') @@ -80,6 +80,6 @@ test('doesn’t log anything if all messages are disabled', async t => { ], }) - await server.destroy() + await hocuspocus.server!.destroy() }) }) diff --git a/tests/server/address.ts b/tests/server/address.ts index 832a84293..11a63ab4d 100644 --- a/tests/server/address.ts +++ b/tests/server/address.ts @@ -2,13 +2,13 @@ import test from 'ava' import { newHocuspocus } from '../utils/index.js' test('returns a dynamic HTTP/WebSocket address with the correct port', async t => { - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ port: 4010, }) - t.is(server.address.port, 4010) - t.is(server.httpURL, 'http://0.0.0.0:4010') - t.is(server.webSocketURL, 'ws://0.0.0.0:4010') + t.is(hocuspocus.server!.address.port, 4010) + t.is(hocuspocus.server!.httpURL, 'http://0.0.0.0:4010') + t.is(hocuspocus.server!.webSocketURL, 'ws://0.0.0.0:4010') t.pass() }) diff --git a/tests/server/listen.ts b/tests/server/listen.ts index c8494cf50..e4c1f594c 100644 --- a/tests/server/listen.ts +++ b/tests/server/listen.ts @@ -1,37 +1,37 @@ import test from 'ava' -import { Hocuspocus } from '@hocuspocus/server' +import { Server } from '@hocuspocus/server' import fetch from 'node-fetch' import { newHocuspocus } from '../utils/index.js' test('should respond with OK', async t => { - const server = await newHocuspocus() + const hocuspocus = await newHocuspocus() - const response = await fetch(server.httpURL) + const response = await fetch(hocuspocus.server!.httpURL) t.is(await response.text(), 'OK') }) test('should respond with status 200', async t => { - const server = await newHocuspocus() + const hocuspocus = await newHocuspocus() - const response = await fetch(server.httpURL) + const response = await fetch(hocuspocus.server!.httpURL) t.is(await response.status, 200) }) test('should respond with OK on a custom port', async t => { - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ port: 4000, }) - const response = await fetch(server.httpURL) + const response = await fetch(hocuspocus.server!.httpURL) - t.is(server.address.port, 4000) + t.is(hocuspocus.server!.address.port, 4000) t.is(await response.text(), 'OK') }) test('should respond with OK on a custom port passed to listen()', async t => { - const server = new Hocuspocus({ + const server = new Server({ port: 0, }) @@ -44,7 +44,7 @@ test('should respond with OK on a custom port passed to listen()', async t => { }) test('should take a custom port and a callback', async t => { - const server = new Hocuspocus({ + const server = new Server({ port: 0, }) @@ -61,12 +61,12 @@ test('should take a custom port and a callback', async t => { }) test('should execute a callback', async t => { - const server = new Hocuspocus({ + const server = new Server({ port: 0, }) await new Promise(async resolve => { - server.listen(async () => { + server.listen(0, async () => { resolve('done') }) }) @@ -77,12 +77,12 @@ test('should execute a callback', async t => { }) test('should have the custom port as a parameter in the callback', async t => { - const server = new Hocuspocus({ + const server = new Server({ port: 0, }) await new Promise(async resolve => { - server.listen(async ({ port }) => { + server.listen(0, async ({ port }: any) => { t.is(port, server.address.port) resolve('done') }) diff --git a/tests/server/onConfigure.ts b/tests/server/onConfigure.ts index 409f99ab5..4946287ec 100644 --- a/tests/server/onConfigure.ts +++ b/tests/server/onConfigure.ts @@ -39,9 +39,9 @@ test('executes onConfigure callback from an extension', async t => { test('has the configuration', async t => { await new Promise(async resolve => { newHocuspocus({ - port: 1337, + debounce: 2001, async onConfigure({ configuration }) { - t.is(configuration.port, 1337) + t.is(configuration.debounce, 2001) resolve('done') }, diff --git a/tests/server/onDestroy.ts b/tests/server/onDestroy.ts index 90de32b92..a7d7de75b 100644 --- a/tests/server/onDestroy.ts +++ b/tests/server/onDestroy.ts @@ -4,23 +4,23 @@ import { retryableAssertion } from '../utils/retryableAssertion' test('executes the onDestroy hook and has the instance', async t => { await new Promise(async resolve => { - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onDestroy({ instance }) { - t.is(instance, server) + t.is(instance, hocuspocus) resolve('done') }, }) - await server.destroy() + await hocuspocus.server!.destroy() }) }) test('destroy works if no document is open', async t => { await new Promise(async resolve => { - const server = await newHocuspocus() + const hocuspocus = await newHocuspocus() - await server.destroy() + await hocuspocus.server!.destroy() t.pass() resolve('') @@ -37,31 +37,31 @@ test('executes the onDestroy hook from a custom extension', async t => { } } - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ extensions: [ new CustomExtension(), ], }) - await server.destroy() + await hocuspocus.server!.destroy() }) }) test('destroy closes all connections', async t => { await new Promise(async resolve => { - const server = await newHocuspocus() + const hocuspocus = await newHocuspocus() - const provider1 = newHocuspocusProvider(server) + const provider1 = newHocuspocusProvider(hocuspocus) await retryableAssertion(t, t2 => t2.is(provider1.synced, true)) - t.is(server.getConnectionsCount(), 1) - t.is(server.getDocumentsCount(), 1) + t.is(hocuspocus.getConnectionsCount(), 1) + t.is(hocuspocus.getDocumentsCount(), 1) - await server.destroy() + await hocuspocus.server!.destroy() - t.is(server.getConnectionsCount(), 0) - t.is(server.getDocumentsCount(), 0) + t.is(hocuspocus.getConnectionsCount(), 0) + t.is(hocuspocus.getDocumentsCount(), 0) resolve('') }) @@ -71,18 +71,18 @@ test('destroy calls onStoreDocument before returning', async t => { await new Promise(async resolve => { let called = false - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onStoreDocument() { called = true }, }) - const provider1 = newHocuspocusProvider(server) + const provider1 = newHocuspocusProvider(hocuspocus) await retryableAssertion(t, t2 => t2.is(provider1.synced, true)) t.is(called, false) - await server.destroy() + await hocuspocus.server!.destroy() t.is(called, true) resolve('') @@ -93,19 +93,19 @@ test('destroy calls onStoreDocument before returning, even with unloadImmediatel await new Promise(async resolve => { let called = false - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onStoreDocument() { called = true }, unloadImmediately: false, }) - const provider1 = newHocuspocusProvider(server) + const provider1 = newHocuspocusProvider(hocuspocus) await retryableAssertion(t, t2 => t2.is(provider1.synced, true)) t.is(called, false) - await server.destroy() + await hocuspocus.server!.destroy() t.is(called, true) resolve('') @@ -116,23 +116,23 @@ test('destroy calls onStoreDocument before returning, even with unloadImmediatel await new Promise(async resolve => { let called = 0 - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onStoreDocument() { called += 1 }, unloadImmediately: false, }) - const provider1 = newHocuspocusProvider(server, { name: 'test1' }) - const provider2 = newHocuspocusProvider(server, { name: 'test2' }) - const provider3 = newHocuspocusProvider(server, { name: 'test3' }) + const provider1 = newHocuspocusProvider(hocuspocus, { name: 'test1' }) + const provider2 = newHocuspocusProvider(hocuspocus, { name: 'test2' }) + const provider3 = newHocuspocusProvider(hocuspocus, { name: 'test3' }) await retryableAssertion(t, t2 => t2.is(provider1.synced, true)) await retryableAssertion(t, t2 => t2.is(provider2.synced, true)) await retryableAssertion(t, t2 => t2.is(provider3.synced, true)) t.is(called, 0) - await server.destroy() + await hocuspocus.server!.destroy() t.is(called, 3) resolve('') @@ -143,23 +143,23 @@ test('destroy calls onStoreDocument before returning, with multiple docs', async await new Promise(async resolve => { let called = 0 - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onStoreDocument() { called += 1 }, unloadImmediately: true, }) - const provider1 = newHocuspocusProvider(server, { name: 'test1' }) - const provider2 = newHocuspocusProvider(server, { name: 'test2' }) - const provider3 = newHocuspocusProvider(server, { name: 'test3' }) + const provider1 = newHocuspocusProvider(hocuspocus, { name: 'test1' }) + const provider2 = newHocuspocusProvider(hocuspocus, { name: 'test2' }) + const provider3 = newHocuspocusProvider(hocuspocus, { name: 'test3' }) await retryableAssertion(t, t2 => t2.is(provider1.synced, true)) await retryableAssertion(t, t2 => t2.is(provider2.synced, true)) await retryableAssertion(t, t2 => t2.is(provider3.synced, true)) t.is(called, 0) - await server.destroy() + await hocuspocus.server!.destroy() t.is(called, 3) resolve('') diff --git a/tests/server/onRequest.ts b/tests/server/onRequest.ts index 21ad238da..680024239 100644 --- a/tests/server/onRequest.ts +++ b/tests/server/onRequest.ts @@ -5,7 +5,7 @@ import { newHocuspocus } from '../utils/index.js' test('executes the onRequest callback', async t => { await new Promise(async resolve => { - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onRequest({ request }: onRequestPayload) { t.is(request.url, '/foobar') @@ -13,7 +13,7 @@ test('executes the onRequest callback', async t => { }, }) - await fetch(`${server.httpURL}/foobar`) + await fetch(`${hocuspocus.server!.httpURL}/foobar`) }) }) @@ -31,13 +31,13 @@ test('executes the onRequest callback of a custom extension', async t => { } } - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ extensions: [ new CustomExtension(), ], }) - const response = await fetch(server.httpURL) + const response = await fetch(hocuspocus.server!.httpURL) t.is(await response.text(), 'I like cats.') resolve('done') }) @@ -45,7 +45,7 @@ test('executes the onRequest callback of a custom extension', async t => { test('can intercept specific URLs', async t => { await new Promise(async resolve => { - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onRequest({ response, request }: onRequestPayload) { if (request.url === '/foobar') { return new Promise((resolve, reject) => { @@ -59,10 +59,10 @@ test('can intercept specific URLs', async t => { }, }) - const interceptedResponse = await fetch(`${server.httpURL}/foobar`) + const interceptedResponse = await fetch(`${hocuspocus.server!.httpURL}/foobar`) t.is(await interceptedResponse.text(), 'I like cats.') - const regularResponse = await fetch(server.httpURL) + const regularResponse = await fetch(hocuspocus.server!.httpURL) t.is(await regularResponse.text(), 'OK') resolve('done') }) @@ -70,13 +70,13 @@ test('can intercept specific URLs', async t => { test('has the instance', async t => { await new Promise(async resolve => { - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ async onRequest({ instance }) { - t.is(instance, server) + t.is(instance, hocuspocus) resolve('done') }, }) - await fetch(`${server.httpURL}/foobar`) + await fetch(`${hocuspocus.server!.httpURL}/foobar`) }) }) diff --git a/tests/server/onStoreDocument.ts b/tests/server/onStoreDocument.ts index 32b55d41d..abbfaf112 100644 --- a/tests/server/onStoreDocument.ts +++ b/tests/server/onStoreDocument.ts @@ -104,7 +104,7 @@ test('debounces document changes for onStoreDocument hooks', async t => { let executedOnChange = 0 let executedOnStoreDocument = 0 - const server = await newHocuspocus({ + const hocuspocus = await newHocuspocus({ debounce: 300, async onChange() { executedOnChange += 1 @@ -120,7 +120,7 @@ test('debounces document changes for onStoreDocument hooks', async t => { }, }) - const provider = newHocuspocusProvider(server, { + const provider = newHocuspocusProvider(hocuspocus, { onSynced() { provider.document.getArray('foo').push(['foo']) provider.document.getArray('foo').push(['bar']) @@ -129,7 +129,7 @@ test('debounces document changes for onStoreDocument hooks', async t => { provider.document.getArray('foo').push(['foofoo']) setTimeout(() => { - server.destroy() + hocuspocus.server!.destroy() }, 200) }, }) diff --git a/tests/utils/newHocuspocus.ts b/tests/utils/newHocuspocus.ts index 6c67d69f0..e6deca116 100644 --- a/tests/utils/newHocuspocus.ts +++ b/tests/utils/newHocuspocus.ts @@ -1,7 +1,7 @@ -import { Hocuspocus, Configuration } from '@hocuspocus/server' +import { Server, ServerConfiguration } from '@hocuspocus/server' -export const newHocuspocus = (options?: Partial) => { - const server = new Hocuspocus({ +export const newHocuspocus = (options?: Partial) => { + const server = new Server({ // We don’t need the logging in testing. quiet: true, // Binding something port 0 will end up on a random free port. diff --git a/tests/utils/newHocuspocusProviderWebsocket.ts b/tests/utils/newHocuspocusProviderWebsocket.ts index dd9ea0543..433014773 100644 --- a/tests/utils/newHocuspocusProviderWebsocket.ts +++ b/tests/utils/newHocuspocusProviderWebsocket.ts @@ -5,13 +5,13 @@ import { Hocuspocus } from '@hocuspocus/server' import WebSocket from 'ws' export const newHocuspocusProviderWebsocket = ( - server: Hocuspocus, + hocuspocus: Hocuspocus, options: Partial> = {}, ) => { return new HocuspocusProviderWebsocket({ // We don’t need which port the server is running on, but // we can get the URL from the passed server instance. - url: server.webSocketURL, + url: hocuspocus.server!.webSocketURL, // Pass a polyfill to use WebSockets in a Node.js environment. WebSocketPolyfill: WebSocket, ...options,