├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode ├── launch.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── rich-text-tiptap │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── richtext.gif │ ├── src │ │ ├── App.tsx │ │ ├── MatrixStatusBar.tsx │ │ ├── MenuBar.tsx │ │ ├── index.tsx │ │ ├── login │ │ │ ├── LoginButton.tsx │ │ │ ├── LoginForm.tsx │ │ │ └── utils.ts │ │ ├── react-app-env.d.ts │ │ └── styles.css │ └── tsconfig.json ├── todo-simple-react-vite │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ └── vite.svg │ ├── src │ │ ├── App.tsx │ │ ├── MatrixStatusBar.tsx │ │ ├── assets │ │ │ └── react.svg │ │ ├── fixMatrixSDK.ts │ │ ├── login │ │ │ ├── LoginButton.tsx │ │ │ ├── LoginForm.tsx │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── store.ts │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── todo-simple-react │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.tsx │ ├── MatrixStatusBar.tsx │ ├── index.tsx │ ├── login │ │ ├── LoginButton.tsx │ │ ├── LoginForm.tsx │ │ └── utils.ts │ ├── react-app-env.d.ts │ └── store.ts │ └── tsconfig.json ├── lerna.json ├── package-lock.json ├── package.json ├── packages └── matrix-crdt │ ├── .npmrc │ ├── LICENSE.md │ ├── package.json │ ├── src │ ├── @types │ │ └── another-json.d.ts │ ├── MatrixCRDTEventTranslator.ts │ ├── MatrixProvider.test.ts │ ├── MatrixProvider.ts │ ├── SignedWebrtcProvider.ts │ ├── benchmark │ │ ├── README.md │ │ ├── benchmarkTest.ts │ │ ├── matrix.bench.ts │ │ └── util.ts │ ├── index.ts │ ├── matrixRoomManagement.ts │ ├── memberReader │ │ ├── MatrixMemberReader.test.ts │ │ └── MatrixMemberReader.ts │ ├── reader │ │ ├── MatrixReader.test.ts │ │ └── MatrixReader.ts │ ├── setupTests.ts │ ├── test-utils │ │ ├── matrixGuestClient.ts │ │ ├── matrixTestUtil.ts │ │ └── matrixTestUtilServer.ts │ ├── util │ │ ├── authUtil.ts │ │ ├── binary.ts │ │ ├── matrixUtil.ts │ │ └── olmlib.ts │ ├── webrtc │ │ ├── DocWebrtcProvider.ts │ │ ├── README.md │ │ ├── Room.ts │ │ ├── SignalingConn.ts │ │ ├── WebrtcConn.ts │ │ ├── WebrtcProvider.ts │ │ ├── crypto.ts │ │ ├── globalResources.ts │ │ └── messageConstants.ts │ └── writer │ │ └── ThrottledMatrixWriter.ts │ ├── tsconfig.json │ └── vite.config.js ├── prettier.config.js ├── test-server ├── .gitignore ├── README.md ├── data │ ├── homeserver.log │ ├── homeserver.yaml │ ├── localhost-8888.log.config │ └── localhost-8888.signing.key └── docker-compose.yml ├── tsconfig.build.json └── tsconfig.json /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@master 11 | 12 | - name: Setup Node.js 16.x 13 | uses: actions/setup-node@master 14 | with: 15 | node-version: 16.x 16 | 17 | - name: Cache node modules 18 | uses: actions/cache@v2 19 | env: 20 | cache-name: cache-node-modules 21 | with: 22 | # npm cache files are stored in `~/.npm` on Linux/macOS 23 | path: ~/.npm 24 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} 25 | restore-keys: | 26 | ${{ runner.os }}-build-${{ env.cache-name }}- 27 | ${{ runner.os }}-build- 28 | ${{ runner.os }}- 29 | 30 | - name: Set correct access for docker containers (server/test/data) 31 | run: chmod -R a+rw test-server/data 32 | 33 | - name: Build the docker-compose stack 34 | run: docker-compose -f test-server/docker-compose.yml up -d 35 | 36 | - name: Check running containers 37 | run: docker ps -a 38 | 39 | - name: Check docker logs 40 | run: docker logs synapse 41 | 42 | - name: Install Dependencies 43 | run: npm run install-lerna 44 | 45 | - name: Bootstrap packages 46 | run: npm run bootstrap 47 | 48 | - name: Wait for Matrix 49 | run: npx wait-on http://localhost:8888/_matrix/static/ 50 | 51 | - name: Build packages 52 | run: npm run build 53 | env: 54 | CI: true 55 | 56 | - name: Run tests 57 | run: npm run test 58 | 59 | - name: Upload to coveralls 60 | uses: coverallsapp/github-action@master 61 | with: 62 | github-token: ${{ secrets.GITHUB_TOKEN }} 63 | path-to-lcov: ./packages/matrix-crdt/coverage/lcov.info 64 | base-path: ./packages/matrix-crdt 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /packages/*/coverage 11 | 12 | # production 13 | /build 14 | 15 | packages/*/dist 16 | packages/*/types 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | ui-debug.log 29 | firebase-debug.log 30 | .env 31 | .eslintcache 32 | packages/matrix-crdt/README.md -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @matrix-org:registry=https://gitlab.matrix.org/api/v4/packages/npm/ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/test/test.ts", 13 | "outFiles": ["${workspaceFolder}/**/*.js"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 5 | "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Development / contributing 2 | 3 | We use [Lerna](https://lerna.js.org/) to manage the monorepo with separate packages. 4 | 5 | ## Running 6 | 7 | Node.js is required to run this project. To download Node.js, visit [nodejs.org](https://nodejs.org/en/). 8 | 9 | To run the project, open the command line in the project's root directory and enter the following commands: 10 | 11 | # Install all required npm modules for lerna, and bootstrap lerna packages 12 | npm run install-lerna 13 | npm run bootstrap 14 | 15 | # Build all projects 16 | npm run build 17 | 18 | # Tests 19 | npm run test 20 | 21 | ## Watch changes 22 | 23 | npm run watch 24 | 25 | ## Updating packages 26 | 27 | If you've pulled changes from git that add new or update existing dependencies, use `npm run bootstrap` instead of `npm install` to install updated dependencies! 28 | 29 | ## Adding packages 30 | 31 | - Add the dependency to the relevant `package.json` file (packages/xxx/packages.json) 32 | - run `npm run install-new-packages` 33 | - Double check `package-lock.json` to make sure only the relevant packages have been affected 34 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/README.md: -------------------------------------------------------------------------------- 1 | A collaborative Rich Text editing experience (similar to Google Docs) powered by [Matrix](https://www.matrix.org), [Yjs](https://github.com/yjs/yjs), [TipTap](https://www.tiptap.dev) and [Matrix-CRDT](https://github.com/yousefED/matrix-crdt). 2 | 3 | - [Open live demo](https://bup9l.csb.app/) (via CodeSandbox) 4 | - [Edit code](https://codesandbox.io/s/github/YousefED/Matrix-CRDT/tree/main/examples/rich-text-tiptap?file=/src/App.tsx) (CodeSandbox) 5 | 6 | ### gif demo: 7 | 8 | ![screenshot](richtext.gif) 9 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "richtext", 3 | "version": "0.2.0", 4 | "private": true, 5 | "dependencies": { 6 | "@primer/css": "^19.1.1", 7 | "@primer/react": "34.1.0", 8 | "@testing-library/jest-dom": "^5.14.1", 9 | "@testing-library/react": "^11.2.7", 10 | "@testing-library/user-event": "^12.8.3", 11 | "@tiptap/extension-collaboration": "^2.0.0-beta.33", 12 | "@tiptap/extension-collaboration-cursor": "^2.0.0-beta.34", 13 | "@tiptap/extension-placeholder": "^2.0.0-beta.47", 14 | "@tiptap/react": "^2.0.0-beta.107", 15 | "@tiptap/starter-kit": "^2.0.0-beta.180", 16 | "@types/node": "^12.20.36", 17 | "@types/react": "^17.0.33", 18 | "@types/react-dom": "^17.0.10", 19 | "babel-loader": "8.1.0", 20 | "eslint": "^7.11.0", 21 | "matrix-crdt": "^0.2.0", 22 | "matrix-js-sdk": "^19.4.0", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "styled-components": "^5.3.3", 26 | "typescript": "^4.4.4", 27 | "url": "^0.11.0", 28 | "y-protocols": "^1.0.5", 29 | "yjs": "^13.5.16" 30 | }, 31 | "devDependencies": { 32 | "react-scripts": "5.0.0" 33 | }, 34 | "scripts": { 35 | "start": "SKIP_PREFLIGHT_CHECK=true react-scripts start", 36 | "package": "react-scripts build", 37 | "test": "react-scripts test", 38 | "eject": "react-scripts eject" 39 | }, 40 | "eslintConfig": { 41 | "extends": [ 42 | "react-app", 43 | "react-app/jest" 44 | ] 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | }, 58 | "workspaces": { 59 | "nohoist": [ 60 | "**" 61 | ], 62 | "packages": [ 63 | "." 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/rich-text-tiptap/public/favicon.ico -------------------------------------------------------------------------------- /examples/rich-text-tiptap/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | Matrix-CRDT Rich Text collaboration demo 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/rich-text-tiptap/public/logo192.png -------------------------------------------------------------------------------- /examples/rich-text-tiptap/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/rich-text-tiptap/public/logo512.png -------------------------------------------------------------------------------- /examples/rich-text-tiptap/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/richtext.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/rich-text-tiptap/richtext.gif -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading } from "@primer/react"; 2 | import Collaboration from "@tiptap/extension-collaboration"; 3 | import Placeholder from "@tiptap/extension-placeholder"; 4 | import { EditorContent, useEditor } from "@tiptap/react"; 5 | import StarterKit from "@tiptap/starter-kit"; 6 | import React from "react"; 7 | import * as Y from "yjs"; 8 | import MatrixStatusBar from "./MatrixStatusBar"; 9 | // import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; 10 | import { MenuBar } from "./MenuBar"; 11 | import "./styles.css"; 12 | 13 | // const colors = [ 14 | // "#958DF1", 15 | // "#F98181", 16 | // "#FBBC88", 17 | // "#FAF594", 18 | // "#70CFF8", 19 | // "#94FADB", 20 | // "#B9F18D", 21 | // ]; 22 | // const names = ["Lea Thompson", "Cyndi Lauper", "Tom Cruise", "Madonna"]; 23 | 24 | // const getRandomElement = (list: any[]) => 25 | // list[Math.floor(Math.random() * list.length)]; 26 | // const getRandomColor = () => getRandomElement(colors); 27 | // const getRandomName = () => getRandomElement(names); 28 | 29 | const yDoc = new Y.Doc(); 30 | const fragment = yDoc.getXmlFragment("richtext"); 31 | 32 | export default function App() { 33 | const editor = useEditor({ 34 | extensions: [ 35 | StarterKit, 36 | Placeholder.configure({ 37 | placeholder: "Write something …", 38 | }), 39 | Collaboration.configure({ 40 | fragment, 41 | }), 42 | // CollaborationCursor.configure({ 43 | // provider: webrtcProvider, 44 | // user: { name: getRandomName(), color: getRandomColor() }, 45 | // }), 46 | ], 47 | }); 48 | 49 | return ( 50 | 51 | {/* This is the top bar with Sign in button and Matrix status 52 | It also takes care of hooking up the Y.Doc to Matrix. 53 | */} 54 | 55 | 56 | Rich text collaboration 57 |

58 | A collaborative Rich Text editing experience (similar to Google Docs) 59 | using Matrix-CRDT. 60 | Edits can be synced to a Matrix Room. Users can work offline and edits 61 | are seamlessly synced when they reconnect to the Matrix room. 62 |

63 | 64 |
65 | 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/MatrixStatusBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ChoiceInputField, Label, Radio } from "@primer/react"; 2 | import { MatrixProvider } from "matrix-crdt"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import React, { useState } from "react"; 5 | import { LoginButton } from "./login/LoginButton"; 6 | import * as Y from "yjs"; 7 | 8 | /** 9 | * The Top Bar of the app that contains the sign in button and status of the MatrixProvider (connection to the Matrix Room) 10 | */ 11 | export default function MatrixStatusBar({ doc }: { doc: Y.Doc }) { 12 | const [isOpen, setIsOpen] = useState(false); 13 | const [matrixProvider, setMatrixProvider] = useState(); 14 | const [status, setStatus] = useState< 15 | "loading" | "failed" | "ok" | "disconnected" 16 | >(); 17 | 18 | const [matrixClient, setMatrixClient] = useState(); 19 | const [roomAlias, setRoomAlias] = useState(); 20 | 21 | const connect = React.useCallback( 22 | (matrixClient: MatrixClient, roomAlias: string) => { 23 | if (!matrixClient || !roomAlias) { 24 | throw new Error("can't connect without matrixClient or roomAlias"); 25 | } 26 | 27 | // This is the main code that sets up the connection between 28 | // yjs and Matrix. It creates a new MatrixProvider and 29 | // registers it to the `doc`. 30 | const newMatrixProvider = new MatrixProvider( 31 | doc, 32 | matrixClient, 33 | { type: "alias", alias: roomAlias }, 34 | undefined, 35 | { 36 | translator: { updatesAsRegularMessages: true }, 37 | reader: { snapshotInterval: 10 }, 38 | writer: { flushInterval: 500 }, 39 | } 40 | ); 41 | setStatus("loading"); 42 | newMatrixProvider.initialize(); 43 | setMatrixProvider(newMatrixProvider); 44 | 45 | // (optional): capture events from MatrixProvider to reflect the status in the UI 46 | newMatrixProvider.onDocumentAvailable((e) => { 47 | setStatus("ok"); 48 | }); 49 | 50 | newMatrixProvider.onCanWriteChanged((e) => { 51 | if (!newMatrixProvider.canWrite) { 52 | setStatus("failed"); 53 | } else { 54 | setStatus("ok"); 55 | } 56 | }); 57 | 58 | newMatrixProvider.onDocumentUnavailable((e) => { 59 | setStatus("failed"); 60 | }); 61 | }, 62 | [doc] 63 | ); 64 | 65 | const onLogin = React.useCallback( 66 | (matrixClient: MatrixClient, roomAlias: string) => { 67 | if (matrixProvider) { 68 | matrixProvider.dispose(); 69 | setStatus("disconnected"); 70 | setMatrixProvider(undefined); 71 | } 72 | 73 | // (optional) stored on state for easy disconnect + connect toggle 74 | setMatrixClient(matrixClient); 75 | setRoomAlias(roomAlias); 76 | 77 | // actually connect 78 | connect(matrixClient, roomAlias); 79 | }, 80 | [matrixProvider, connect] 81 | ); 82 | 83 | const onConnectChange = React.useCallback( 84 | (e: React.ChangeEvent) => { 85 | if (!matrixClient || !roomAlias) { 86 | throw new Error("matrixClient and roomAlias should be set"); 87 | } 88 | 89 | if (matrixProvider) { 90 | matrixProvider.dispose(); 91 | setStatus("disconnected"); 92 | setMatrixProvider(undefined); 93 | } 94 | 95 | if (e.target.value === "true") { 96 | connect(matrixClient, roomAlias); 97 | } 98 | }, 99 | [connect, matrixClient, roomAlias, matrixProvider] 100 | ); 101 | 102 | return ( 103 | 104 | {/* TODO: add options to go offline / webrtc, snapshots etc */} 105 | {status === undefined && ( 106 | 107 | )} 108 | {matrixClient && ( 109 |
110 | 111 | Online 112 | 118 | 119 | 120 | 121 | Offline (disable sync) 122 | 123 | 129 | 130 |
131 | )} 132 | {status === "loading" && ( 133 | 136 | )} 137 | {status === "disconnected" && ( 138 | 141 | )} 142 | {status === "ok" && ( 143 | 149 | )} 150 | {status === "failed" && ( 151 | 157 | )} 158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/MenuBar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/role-supports-aria-props */ 2 | import { Button } from "@primer/react"; 3 | 4 | export const MenuBar = ({ editor }: any) => { 5 | if (!editor) { 6 | return null; 7 | } 8 | 9 | return ( 10 | <> 11 |
12 |
13 | 19 | 25 | 31 | 37 |
38 |
39 | 44 | 49 |
50 |
51 |
52 |
53 | 61 | 69 | 77 | 85 | 93 | 101 |
102 |
103 |
104 |
105 | 111 | 117 | 123 | 129 | 135 |
136 |
137 |
138 |
139 | 144 | 149 | 154 | 159 |
160 |
161 | 162 | ); 163 | }; 164 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import { ThemeProvider, BaseStyles } from "@primer/react"; 5 | ReactDOM.render( 6 | 7 | 8 | 9 | 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/login/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonPrimary, Text } from "@primer/react"; 2 | import { Dialog } from "@primer/react/lib/Dialog/Dialog"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import React from "react"; 5 | import LoginForm from "./LoginForm"; 6 | import { createMatrixClient, LoginData } from "./utils"; 7 | 8 | export const LoginButton = ({ 9 | isOpen, 10 | setIsOpen, 11 | onLogin, 12 | }: { 13 | isOpen: boolean; 14 | setIsOpen: (open: boolean) => void; 15 | onLogin: (client: MatrixClient, roomAlias: string) => void; 16 | }) => { 17 | const [loginData, setLoginData] = React.useState(); 18 | const [status, setStatus] = React.useState<"ok" | "loading" | "failed">("ok"); 19 | const openDialog = React.useCallback(() => setIsOpen(true), [setIsOpen]); 20 | const closeDialog = React.useCallback(() => { 21 | setIsOpen(false); 22 | }, [setIsOpen]); 23 | 24 | const doLogin = React.useCallback(() => { 25 | setStatus("loading"); 26 | (async () => { 27 | try { 28 | const matrixClient = await createMatrixClient(loginData!); 29 | setIsOpen(false); 30 | onLogin(matrixClient, loginData!.roomAlias); 31 | setStatus("ok"); 32 | } catch (e) { 33 | setStatus("failed"); 34 | } 35 | })(); 36 | }, [setIsOpen, loginData, onLogin]); 37 | 38 | return ( 39 | <> 40 | Sign in with Matrix 41 | {isOpen && ( 42 | 46 | // This is a description of the dialog. 47 | // 48 | // } 49 | renderFooter={(props) => ( 50 | 51 | 52 | Support for OpenID / OAuth is{" "} 53 | 57 | in progress 58 | 59 | . 60 | 61 | 62 | 63 | )} 64 | footerButtons={[ 65 | { 66 | content: "Sign in", 67 | buttonType: "primary", 68 | disabled: status === "loading", 69 | onClick: doLogin, 70 | }, 71 | ]} 72 | onClose={closeDialog}> 73 | 74 | 75 | )} 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | ChoiceInputField, 4 | FormGroup, 5 | InputField, 6 | Radio, 7 | TextInput, 8 | Flash, 9 | } from "@primer/react"; 10 | import React, { useState } from "react"; 11 | import { LoginData } from "./utils"; 12 | 13 | export default function LoginForm({ 14 | setLoginData, 15 | status, 16 | }: { 17 | setLoginData: (data: LoginData) => void; 18 | status: "loading" | "failed" | "ok"; 19 | }) { 20 | const [server, setServer] = useState("https://matrix.org"); 21 | const [user, setUser] = useState("@yousefed:matrix.org"); 22 | const [token, setToken] = useState(""); 23 | const [password, setPassword] = useState(""); 24 | const [roomAlias, setRoomAlias] = useState("#matrix-crdt-test:matrix.org"); 25 | const [authMethod, setAuthMethod] = useState<"password" | "token">( 26 | "password" 27 | ); 28 | const [validationResult, setValidationResult] = useState< 29 | "format" | "prefix" 30 | >(); 31 | 32 | React.useEffect(() => { 33 | if (!/#matrix-crdt-.*/.test(roomAlias)) { 34 | setValidationResult("prefix"); 35 | } else if (!/#.+:.+/.test(roomAlias)) { 36 | setValidationResult("format"); 37 | } else { 38 | setValidationResult(undefined); 39 | } 40 | }, [roomAlias]); 41 | 42 | React.useEffect(() => { 43 | setLoginData({ 44 | server, 45 | user, 46 | token, 47 | password, 48 | roomAlias, 49 | authMethod, 50 | }); 51 | }, [setLoginData, server, user, token, password, roomAlias, authMethod]); 52 | 53 | return ( 54 |
55 | 56 | {status === "failed" && Sign in failed} 57 | 58 | 59 | Homeserver: 60 | setServer(e.target.value)} 62 | defaultValue={server} 63 | /> 64 | 65 | 66 | 67 | 68 | Matrix user id: 69 | setUser(e.target.value)} 71 | defaultValue={user} 72 | placeholder="e.g.: @yousefed:matrix.org" 73 | /> 74 | 75 | 76 |
77 | 78 | 79 | Sign in with password 80 | 81 | setAuthMethod(e.target.value)} 86 | /> 87 | 88 | 89 | 90 | Sign in with Access Token 91 | 92 | setAuthMethod(e.target.value)} 97 | /> 98 | 99 |
100 | {authMethod === "token" && ( 101 | 102 | 103 | Access token: 104 | setToken(e.target.value)} 107 | defaultValue={token} 108 | /> 109 | 110 | You can find your access token in Element Settings -> Help & 111 | About. Your access token is only shared with the Matrix server. 112 | 113 | 114 | 115 | )} 116 | {authMethod === "password" && ( 117 | 118 | 119 | Password: 120 | setPassword(e.target.value)} 124 | defaultValue={password} 125 | /> 126 | 127 | Your password is only shared with the Matrix server. 128 | 129 | 130 | 131 | )} 132 | 133 | 140 | Room alias: 141 | setRoomAlias(e.target.value)} 143 | defaultValue={roomAlias} 144 | placeholder="e.g.: #matrix-crdt-test:matrix.org" 145 | /> 146 | 147 | The room alias must start "#matrix-crdt-" for testing purposes. 148 | 149 | 150 | Room aliases should be of the format #alias:server.tld 151 | 152 | 153 | The room that application state will be synced with. 154 | 155 | 156 | 157 |
158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/login/utils.ts: -------------------------------------------------------------------------------- 1 | import sdk from "matrix-js-sdk"; 2 | 3 | export type LoginData = { 4 | server: string; 5 | user: string; 6 | roomAlias: string; 7 | authMethod: "password" | "token"; 8 | password: string; 9 | token: string; 10 | }; 11 | 12 | export async function createMatrixClient(data: LoginData) { 13 | const signInOpts = { 14 | baseUrl: data.server, 15 | 16 | userId: data.user, 17 | }; 18 | 19 | const matrixClient = 20 | data.authMethod === "token" 21 | ? sdk.createClient({ 22 | ...signInOpts, 23 | accessToken: data.token, 24 | }) 25 | : sdk.createClient(signInOpts); 26 | 27 | if (data.authMethod === "token") { 28 | await matrixClient.loginWithToken(data.token); 29 | } else { 30 | await matrixClient.login("m.login.password", { 31 | user: data.user, 32 | password: data.password, 33 | }); 34 | } 35 | 36 | // overwrites because we don't call .start(); 37 | (matrixClient as any).canSupportVoip = false; 38 | (matrixClient as any).clientOpts = { 39 | lazyLoadMembers: true, 40 | }; 41 | return matrixClient; 42 | } 43 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/src/styles.css: -------------------------------------------------------------------------------- 1 | @import "https://unpkg.com/@primer/css@^16.0.0/dist/primer.css"; 2 | 3 | body { 4 | background-color: #f8f9fa; 5 | } 6 | 7 | .editor { 8 | background: white; 9 | box-shadow: rgba(14, 23, 31, 0.15) 0px 1px 3px 1px; 10 | padding: 2em; 11 | margin-top: 1em; 12 | } 13 | 14 | .BtnGroup { 15 | margin-bottom: 0.5em; 16 | } 17 | 18 | .description { 19 | font-style: italic; 20 | } 21 | 22 | /* Basic editor styles */ 23 | .ProseMirror > * + * { 24 | margin-top: 0.75em; 25 | } 26 | 27 | /* Placeholder (at the top) */ 28 | .ProseMirror p.is-editor-empty:first-child::before { 29 | color: #adb5bd; 30 | content: attr(data-placeholder); 31 | float: left; 32 | height: 0; 33 | pointer-events: none; 34 | } 35 | 36 | .ProseMirror { 37 | min-height: 500px; 38 | border: 0; 39 | outline: none; 40 | } 41 | 42 | .ProseMirror ul, 43 | .ProseMirror ol { 44 | padding-left: 1.2em; 45 | } 46 | .ProseMirror code { 47 | background-color: rgba(#616161, 0.1); 48 | color: #616161; 49 | } 50 | 51 | .ProseMirror pre { 52 | background: #0d0d0d; 53 | color: #fff; 54 | font-family: "JetBrainsMono", monospace; 55 | padding: 0.75rem 1rem; 56 | border-radius: 0.5rem; 57 | } 58 | .ProseMirror pre code { 59 | color: inherit; 60 | padding: 0; 61 | background: none; 62 | font-size: 0.8rem; 63 | } 64 | 65 | .ProseMirror img { 66 | max-width: 100%; 67 | height: auto; 68 | } 69 | 70 | .ProseMirror blockquote { 71 | padding-left: 1rem; 72 | border-left: 2px solid rgba(13, 13, 13, 0.1); 73 | } 74 | 75 | .ProseMirror hr { 76 | border: none; 77 | border-top: 2px solid rgba(13, 13, 13, 0.1); 78 | margin: 2rem 0; 79 | } 80 | 81 | /* Give a remote user a caret */ 82 | .collaboration-cursor__caret { 83 | border-left: 1px solid #0d0d0d; 84 | border-right: 1px solid #0d0d0d; 85 | margin-left: -1px; 86 | margin-right: -1px; 87 | pointer-events: none; 88 | position: relative; 89 | word-break: normal; 90 | } 91 | 92 | /* Render the username above the caret */ 93 | .collaboration-cursor__label { 94 | border-radius: 3px 3px 3px 0; 95 | color: #0d0d0d; 96 | font-size: 12px; 97 | font-style: normal; 98 | font-weight: 600; 99 | left: -1px; 100 | line-height: normal; 101 | padding: 0.1rem 0.3rem; 102 | position: absolute; 103 | top: -1.4em; 104 | user-select: none; 105 | white-space: nowrap; 106 | } 107 | -------------------------------------------------------------------------------- /examples/rich-text-tiptap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/README.md: -------------------------------------------------------------------------------- 1 | A simple TODO app example using SyncedStore and Matrix-CRDT, created with Vite (3) React (18) 2 | TODOs are saved in a Matrix Room. 3 | 4 | - [Open live demo](https://4s9bum.sse.codesandbox.io/) (via CodeSandbox) 5 | - [Edit code](https://codesandbox.io/s/github/YousefED/Matrix-CRDT/tree/main/examples/todo-simple-react-vite?file=/src/App.tsx) (CodeSandbox) 6 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@primer/react": "34.1.0", 13 | "@syncedstore/core": "^0.3.5", 14 | "@syncedstore/react": "^0.3.5", 15 | "@types/node": "^12.20.36", 16 | "@types/react": "^17.0.33", 17 | "@types/react-dom": "^17.0.10", 18 | "babel-loader": "8.1.0", 19 | "eslint": "^7.11.0", 20 | "matrix-crdt": "^0.2.0", 21 | "matrix-js-sdk": "^19.4.0", 22 | "styled-components": "^5.3.3", 23 | "typescript": "^4.4.4", 24 | "url": "^0.11.0", 25 | "y-protocols": "^1.0.5", 26 | "yjs": "^13.5.16", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "events": "3.3.0" 30 | }, 31 | "devDependencies": { 32 | "@types/react": "^18.0.24", 33 | "@types/react-dom": "^18.0.8", 34 | "@vitejs/plugin-react": "^2.2.0", 35 | "typescript": "^4.6.4", 36 | "vite": "^3.2.3", 37 | "rollup-plugin-polyfill-node": "^0.10.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Checkbox, Heading, Text, TextInput } from "@primer/react"; 2 | import { getYjsValue } from "@syncedstore/core"; 3 | import { useSyncedStore } from "@syncedstore/react"; 4 | import * as Y from "yjs"; 5 | import React from "react"; 6 | import MatrixStatusBar from "./MatrixStatusBar"; 7 | import { globalStore } from "./store"; 8 | 9 | export default function App() { 10 | const state = useSyncedStore(globalStore); 11 | 12 | return ( 13 | 14 | {/* This is the top bar with Sign in button and Matrix status 15 | It also takes care of hooking up the Y.Doc to Matrix. 16 | */} 17 | 18 | 19 | Todo items: 20 | 21 | { 28 | if (event.key === "Enter" && event.target.value) { 29 | const target = event.target as HTMLInputElement; 30 | // Add a todo item using the text added in the textfield 31 | state.todos.push({ completed: false, title: target.value }); 32 | target.value = ""; 33 | } 34 | }} 35 | /> 36 | 37 | {state.todos.map((todo, i) => { 38 | return ( 39 | 43 | (todo.completed = !todo.completed)} 47 | /> 48 | 52 | {todo.title} 53 | 54 | 55 | ); 56 | })} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/MatrixStatusBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ChoiceInputField, Label, Radio } from "@primer/react"; 2 | import { MatrixProvider } from "matrix-crdt"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import React, { useState } from "react"; 5 | import { LoginButton } from "./login/LoginButton"; 6 | import * as Y from "yjs"; 7 | 8 | /** 9 | * The Top Bar of the app that contains the sign in button and status of the MatrixProvider (connection to the Matrix Room) 10 | */ 11 | export default function MatrixStatusBar({ doc }: { doc: Y.Doc }) { 12 | const [isOpen, setIsOpen] = useState(false); 13 | const [matrixProvider, setMatrixProvider] = useState(); 14 | const [status, setStatus] = useState< 15 | "loading" | "failed" | "ok" | "disconnected" 16 | >(); 17 | 18 | const [matrixClient, setMatrixClient] = useState(); 19 | const [roomAlias, setRoomAlias] = useState(); 20 | 21 | const connect = React.useCallback( 22 | (matrixClient: MatrixClient, roomAlias: string) => { 23 | if (!matrixClient || !roomAlias) { 24 | throw new Error("can't connect without matrixClient or roomAlias"); 25 | } 26 | 27 | // This is the main code that sets up the connection between 28 | // yjs and Matrix. It creates a new MatrixProvider and 29 | // registers it to the `doc`. 30 | const newMatrixProvider = new MatrixProvider( 31 | doc, 32 | matrixClient, 33 | { type: "alias", alias: roomAlias }, 34 | undefined, 35 | { 36 | translator: { updatesAsRegularMessages: true }, 37 | reader: { snapshotInterval: 10 }, 38 | writer: { flushInterval: 500 }, 39 | } 40 | ); 41 | setStatus("loading"); 42 | newMatrixProvider.initialize(); 43 | setMatrixProvider(newMatrixProvider); 44 | 45 | // (optional): capture events from MatrixProvider to reflect the status in the UI 46 | newMatrixProvider.onDocumentAvailable((e) => { 47 | setStatus("ok"); 48 | }); 49 | 50 | newMatrixProvider.onCanWriteChanged((e) => { 51 | if (!newMatrixProvider.canWrite) { 52 | setStatus("failed"); 53 | } else { 54 | setStatus("ok"); 55 | } 56 | }); 57 | 58 | newMatrixProvider.onDocumentUnavailable((e) => { 59 | setStatus("failed"); 60 | }); 61 | }, 62 | [doc] 63 | ); 64 | 65 | const onLogin = React.useCallback( 66 | (matrixClient: MatrixClient, roomAlias: string) => { 67 | if (matrixProvider) { 68 | matrixProvider.dispose(); 69 | setStatus("disconnected"); 70 | setMatrixProvider(undefined); 71 | } 72 | 73 | // (optional) stored on state for easy disconnect + connect toggle 74 | setMatrixClient(matrixClient); 75 | setRoomAlias(roomAlias); 76 | 77 | // actually connect 78 | connect(matrixClient, roomAlias); 79 | }, 80 | [matrixProvider, connect] 81 | ); 82 | 83 | const onConnectChange = React.useCallback( 84 | (e: React.ChangeEvent) => { 85 | if (!matrixClient || !roomAlias) { 86 | throw new Error("matrixClient and roomAlias should be set"); 87 | } 88 | 89 | if (matrixProvider) { 90 | matrixProvider.dispose(); 91 | setStatus("disconnected"); 92 | setMatrixProvider(undefined); 93 | } 94 | 95 | if (e.target.value === "true") { 96 | connect(matrixClient, roomAlias); 97 | } 98 | }, 99 | [connect, matrixClient, roomAlias, matrixProvider] 100 | ); 101 | 102 | return ( 103 | 104 | {/* TODO: add options to go offline / webrtc, snapshots etc */} 105 | {status === undefined && ( 106 | 107 | )} 108 | {matrixClient && ( 109 |
110 | 111 | Online 112 | 118 | 119 | 120 | 121 | Offline (disable sync) 122 | 123 | 129 | 130 |
131 | )} 132 | {status === "loading" && ( 133 | 136 | )} 137 | {status === "disconnected" && ( 138 | 141 | )} 142 | {status === "ok" && ( 143 | 149 | )} 150 | {status === "failed" && ( 151 | 157 | )} 158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/fixMatrixSDK.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | import * as process from "process"; 3 | 4 | /** 5 | * Matrix-js-sdk doesn't work nicely without these globals 6 | * 7 | * Also needs global = window, and nodePolyfills set in vite.config.ts 8 | */ 9 | export function applyMatrixSDKPolyfills() { 10 | (window as any).Buffer = Buffer; 11 | (window as any).process = process; 12 | } 13 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/login/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonPrimary, Text } from "@primer/react"; 2 | import { Dialog } from "@primer/react/lib/Dialog/Dialog"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import React from "react"; 5 | import LoginForm from "./LoginForm"; 6 | import { createMatrixClient, LoginData } from "./utils"; 7 | 8 | export const LoginButton = ({ 9 | isOpen, 10 | setIsOpen, 11 | onLogin, 12 | }: { 13 | isOpen: boolean; 14 | setIsOpen: (open: boolean) => void; 15 | onLogin: (client: MatrixClient, roomAlias: string) => void; 16 | }) => { 17 | const [loginData, setLoginData] = React.useState(); 18 | const [status, setStatus] = React.useState<"ok" | "loading" | "failed">("ok"); 19 | const openDialog = React.useCallback(() => setIsOpen(true), [setIsOpen]); 20 | const closeDialog = React.useCallback(() => { 21 | setIsOpen(false); 22 | }, [setIsOpen]); 23 | 24 | const doLogin = React.useCallback(() => { 25 | setStatus("loading"); 26 | (async () => { 27 | try { 28 | const matrixClient = await createMatrixClient(loginData!); 29 | setIsOpen(false); 30 | onLogin(matrixClient, loginData!.roomAlias); 31 | setStatus("ok"); 32 | } catch (e) { 33 | setStatus("failed"); 34 | } 35 | })(); 36 | }, [setIsOpen, loginData, onLogin]); 37 | 38 | return ( 39 | <> 40 | Sign in with Matrix 41 | {isOpen && ( 42 | 46 | // This is a description of the dialog. 47 | // 48 | // } 49 | renderFooter={(props) => ( 50 | 51 | 52 | Support for OpenID / OAuth is{" "} 53 | 57 | in progress 58 | 59 | . 60 | 61 | 62 | 63 | )} 64 | footerButtons={[ 65 | { 66 | content: "Sign in", 67 | buttonType: "primary", 68 | disabled: status === "loading", 69 | onClick: doLogin, 70 | }, 71 | ]} 72 | onClose={closeDialog}> 73 | 74 | 75 | )} 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | ChoiceInputField, 4 | FormGroup, 5 | InputField, 6 | Radio, 7 | TextInput, 8 | Flash, 9 | } from "@primer/react"; 10 | import React, { useState } from "react"; 11 | import { LoginData } from "./utils"; 12 | 13 | export default function LoginForm({ 14 | setLoginData, 15 | status, 16 | }: { 17 | setLoginData: (data: LoginData) => void; 18 | status: "loading" | "failed" | "ok"; 19 | }) { 20 | const [server, setServer] = useState("https://matrix.org"); 21 | const [user, setUser] = useState("@yousefed:matrix.org"); 22 | const [token, setToken] = useState(""); 23 | const [password, setPassword] = useState(""); 24 | const [roomAlias, setRoomAlias] = useState("#matrix-crdt-test:matrix.org"); 25 | const [authMethod, setAuthMethod] = useState<"password" | "token">( 26 | "password" 27 | ); 28 | const [validationResult, setValidationResult] = useState< 29 | "format" | "prefix" 30 | >(); 31 | 32 | React.useEffect(() => { 33 | if (!/#matrix-crdt-.*/.test(roomAlias)) { 34 | setValidationResult("prefix"); 35 | } else if (!/#.+:.+/.test(roomAlias)) { 36 | setValidationResult("format"); 37 | } else { 38 | setValidationResult(undefined); 39 | } 40 | }, [roomAlias]); 41 | 42 | React.useEffect(() => { 43 | setLoginData({ 44 | server, 45 | user, 46 | token, 47 | password, 48 | roomAlias, 49 | authMethod, 50 | }); 51 | }, [setLoginData, server, user, token, password, roomAlias, authMethod]); 52 | 53 | return ( 54 |
55 | 56 | {status === "failed" && Sign in failed} 57 | 58 | 59 | Homeserver: 60 | setServer(e.target.value)} 62 | defaultValue={server} 63 | /> 64 | 65 | 66 | 67 | 68 | Matrix user id: 69 | setUser(e.target.value)} 71 | defaultValue={user} 72 | placeholder="e.g.: @yousefed:matrix.org" 73 | /> 74 | 75 | 76 |
77 | 78 | 79 | Sign in with password 80 | 81 | setAuthMethod(e.target.value)} 86 | /> 87 | 88 | 89 | 90 | Sign in with Access Token 91 | 92 | setAuthMethod(e.target.value)} 97 | /> 98 | 99 |
100 | {authMethod === "token" && ( 101 | 102 | 103 | Access token: 104 | setToken(e.target.value)} 107 | defaultValue={token} 108 | /> 109 | 110 | You can find your access token in Element Settings -> Help & 111 | About. Your access token is only shared with the Matrix server. 112 | 113 | 114 | 115 | )} 116 | {authMethod === "password" && ( 117 | 118 | 119 | Password: 120 | setPassword(e.target.value)} 124 | defaultValue={password} 125 | /> 126 | 127 | Your password is only shared with the Matrix server. 128 | 129 | 130 | 131 | )} 132 | 133 | 140 | Room alias: 141 | setRoomAlias(e.target.value)} 143 | defaultValue={roomAlias} 144 | placeholder="e.g.: #matrix-crdt-test:matrix.org" 145 | /> 146 | 147 | The room alias must start "#matrix-crdt-" for testing purposes. 148 | 149 | 150 | Room aliases should be of the format #alias:server.tld 151 | 152 | 153 | The room that application state will be synced with. 154 | 155 | 156 | 157 |
158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/login/utils.ts: -------------------------------------------------------------------------------- 1 | import sdk from "matrix-js-sdk"; 2 | 3 | export type LoginData = { 4 | server: string; 5 | user: string; 6 | roomAlias: string; 7 | authMethod: "password" | "token"; 8 | password: string; 9 | token: string; 10 | }; 11 | 12 | export async function createMatrixClient(data: LoginData) { 13 | const signInOpts = { 14 | baseUrl: data.server, 15 | 16 | userId: data.user, 17 | }; 18 | 19 | const matrixClient = 20 | data.authMethod === "token" 21 | ? sdk.createClient({ 22 | ...signInOpts, 23 | accessToken: data.token, 24 | }) 25 | : sdk.createClient(signInOpts); 26 | 27 | if (data.authMethod === "token") { 28 | await matrixClient.loginWithToken(data.token); 29 | } else { 30 | await matrixClient.login("m.login.password", { 31 | user: data.user, 32 | password: data.password, 33 | }); 34 | } 35 | 36 | // overwrites because we don't call .start(); 37 | (matrixClient as any).canSupportVoip = false; 38 | (matrixClient as any).clientOpts = { 39 | lazyLoadMembers: true, 40 | }; 41 | return matrixClient; 42 | } 43 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { BaseStyles, ThemeProvider } from "@primer/react"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import App from "./App"; 5 | import { applyMatrixSDKPolyfills } from "./fixMatrixSDK"; 6 | 7 | applyMatrixSDKPolyfills(); 8 | 9 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/store.ts: -------------------------------------------------------------------------------- 1 | import { syncedStore } from "@syncedstore/core"; 2 | 3 | export type Todo = { 4 | title: string; 5 | completed: boolean; 6 | }; 7 | 8 | export const globalStore = syncedStore({ todos: [] as Todo[] }); 9 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /examples/todo-simple-react-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import nodePolyfills from "rollup-plugin-polyfill-node"; 3 | import { defineConfig } from "vite"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | build: { 9 | // required for @primer/react 10 | commonjsOptions: { 11 | transformMixedEsModules: true, 12 | }, 13 | minify: false, 14 | // needed for matrix-js-sdk: 15 | rollupOptions: { 16 | // Enable rollup polyfills plugin 17 | // used during production bundling 18 | plugins: [nodePolyfills()], 19 | }, 20 | }, 21 | // needed for matrix-js-sdk: 22 | define: { global: "window" }, 23 | }); 24 | -------------------------------------------------------------------------------- /examples/todo-simple-react/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/todo-simple-react/README.md: -------------------------------------------------------------------------------- 1 | A simple TODO app example using SyncedStore and Matrix-CRDT. 2 | TODOs are saved in a Matrix Room. 3 | 4 | - [Open live demo](https://klwxt.csb.app/) (via CodeSandbox) 5 | - [Edit code](https://codesandbox.io/s/github/YousefED/Matrix-CRDT/tree/main/examples/todo-simple-react?file=/src/App.tsx) (CodeSandbox) 6 | -------------------------------------------------------------------------------- /examples/todo-simple-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo", 3 | "version": "0.2.0", 4 | "private": true, 5 | "dependencies": { 6 | "@primer/react": "34.1.0", 7 | "@syncedstore/core": "^0.3.5", 8 | "@syncedstore/react": "^0.3.5", 9 | "@types/node": "^12.20.36", 10 | "@types/react": "^17.0.33", 11 | "@types/react-dom": "^17.0.10", 12 | "babel-loader": "8.1.0", 13 | "eslint": "^7.11.0", 14 | "matrix-crdt": "^0.2.0", 15 | "matrix-js-sdk": "^19.4.0", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2", 18 | "styled-components": "^5.3.3", 19 | "typescript": "^4.4.4", 20 | "url": "^0.11.0", 21 | "y-protocols": "^1.0.5", 22 | "yjs": "^13.5.16" 23 | }, 24 | "devDependencies": { 25 | "react-scripts": "5.0.0" 26 | }, 27 | "scripts": { 28 | "start": "SKIP_PREFLIGHT_CHECK=true react-scripts start", 29 | "package": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app", 36 | "react-app/jest" 37 | ] 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "workspaces": { 52 | "nohoist": [ 53 | "**" 54 | ], 55 | "packages": [ 56 | "." 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /examples/todo-simple-react/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/todo-simple-react/public/favicon.ico -------------------------------------------------------------------------------- /examples/todo-simple-react/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | Matrix-CRDT TODO collaboration demo 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /examples/todo-simple-react/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/todo-simple-react/public/logo192.png -------------------------------------------------------------------------------- /examples/todo-simple-react/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/examples/todo-simple-react/public/logo512.png -------------------------------------------------------------------------------- /examples/todo-simple-react/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/todo-simple-react/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Checkbox, Heading, Text, TextInput } from "@primer/react"; 2 | import { getYjsValue } from "@syncedstore/core"; 3 | import { useSyncedStore } from "@syncedstore/react"; 4 | import * as Y from "yjs"; 5 | import React from "react"; 6 | import MatrixStatusBar from "./MatrixStatusBar"; 7 | import { globalStore } from "./store"; 8 | 9 | export default function App() { 10 | const state = useSyncedStore(globalStore); 11 | 12 | return ( 13 | 14 | {/* This is the top bar with Sign in button and Matrix status 15 | It also takes care of hooking up the Y.Doc to Matrix. 16 | */} 17 | 18 | 19 | Todo items: 20 | 21 | { 28 | if (event.key === "Enter" && event.target.value) { 29 | const target = event.target as HTMLInputElement; 30 | // Add a todo item using the text added in the textfield 31 | state.todos.push({ completed: false, title: target.value }); 32 | target.value = ""; 33 | } 34 | }} 35 | /> 36 | 37 | {state.todos.map((todo, i) => { 38 | return ( 39 | 43 | (todo.completed = !todo.completed)} 47 | /> 48 | 52 | {todo.title} 53 | 54 | 55 | ); 56 | })} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/MatrixStatusBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ChoiceInputField, Label, Radio } from "@primer/react"; 2 | import { MatrixProvider } from "matrix-crdt"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import React, { useState } from "react"; 5 | import { LoginButton } from "./login/LoginButton"; 6 | import * as Y from "yjs"; 7 | 8 | /** 9 | * The Top Bar of the app that contains the sign in button and status of the MatrixProvider (connection to the Matrix Room) 10 | */ 11 | export default function MatrixStatusBar({ doc }: { doc: Y.Doc }) { 12 | const [isOpen, setIsOpen] = useState(false); 13 | const [matrixProvider, setMatrixProvider] = useState(); 14 | const [status, setStatus] = useState< 15 | "loading" | "failed" | "ok" | "disconnected" 16 | >(); 17 | 18 | const [matrixClient, setMatrixClient] = useState(); 19 | const [roomAlias, setRoomAlias] = useState(); 20 | 21 | const connect = React.useCallback( 22 | (matrixClient: MatrixClient, roomAlias: string) => { 23 | if (!matrixClient || !roomAlias) { 24 | throw new Error("can't connect without matrixClient or roomAlias"); 25 | } 26 | 27 | // This is the main code that sets up the connection between 28 | // yjs and Matrix. It creates a new MatrixProvider and 29 | // registers it to the `doc`. 30 | const newMatrixProvider = new MatrixProvider( 31 | doc, 32 | matrixClient, 33 | { type: "alias", alias: roomAlias }, 34 | undefined, 35 | { 36 | translator: { updatesAsRegularMessages: true }, 37 | reader: { snapshotInterval: 10 }, 38 | writer: { flushInterval: 500 }, 39 | } 40 | ); 41 | setStatus("loading"); 42 | newMatrixProvider.initialize(); 43 | setMatrixProvider(newMatrixProvider); 44 | 45 | // (optional): capture events from MatrixProvider to reflect the status in the UI 46 | newMatrixProvider.onDocumentAvailable((e) => { 47 | setStatus("ok"); 48 | }); 49 | 50 | newMatrixProvider.onCanWriteChanged((e) => { 51 | if (!newMatrixProvider.canWrite) { 52 | setStatus("failed"); 53 | } else { 54 | setStatus("ok"); 55 | } 56 | }); 57 | 58 | newMatrixProvider.onDocumentUnavailable((e) => { 59 | setStatus("failed"); 60 | }); 61 | }, 62 | [doc] 63 | ); 64 | 65 | const onLogin = React.useCallback( 66 | (matrixClient: MatrixClient, roomAlias: string) => { 67 | if (matrixProvider) { 68 | matrixProvider.dispose(); 69 | setStatus("disconnected"); 70 | setMatrixProvider(undefined); 71 | } 72 | 73 | // (optional) stored on state for easy disconnect + connect toggle 74 | setMatrixClient(matrixClient); 75 | setRoomAlias(roomAlias); 76 | 77 | // actually connect 78 | connect(matrixClient, roomAlias); 79 | }, 80 | [matrixProvider, connect] 81 | ); 82 | 83 | const onConnectChange = React.useCallback( 84 | (e: React.ChangeEvent) => { 85 | if (!matrixClient || !roomAlias) { 86 | throw new Error("matrixClient and roomAlias should be set"); 87 | } 88 | 89 | if (matrixProvider) { 90 | matrixProvider.dispose(); 91 | setStatus("disconnected"); 92 | setMatrixProvider(undefined); 93 | } 94 | 95 | if (e.target.value === "true") { 96 | connect(matrixClient, roomAlias); 97 | } 98 | }, 99 | [connect, matrixClient, roomAlias, matrixProvider] 100 | ); 101 | 102 | return ( 103 | 104 | {/* TODO: add options to go offline / webrtc, snapshots etc */} 105 | {status === undefined && ( 106 | 107 | )} 108 | {matrixClient && ( 109 |
110 | 111 | Online 112 | 118 | 119 | 120 | 121 | Offline (disable sync) 122 | 123 | 129 | 130 |
131 | )} 132 | {status === "loading" && ( 133 | 136 | )} 137 | {status === "disconnected" && ( 138 | 141 | )} 142 | {status === "ok" && ( 143 | 149 | )} 150 | {status === "failed" && ( 151 | 157 | )} 158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import { ThemeProvider, BaseStyles } from "@primer/react"; 5 | ReactDOM.render( 6 | 7 | 8 | 9 | 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/login/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonPrimary, Text } from "@primer/react"; 2 | import { Dialog } from "@primer/react/lib/Dialog/Dialog"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import React from "react"; 5 | import LoginForm from "./LoginForm"; 6 | import { createMatrixClient, LoginData } from "./utils"; 7 | 8 | export const LoginButton = ({ 9 | isOpen, 10 | setIsOpen, 11 | onLogin, 12 | }: { 13 | isOpen: boolean; 14 | setIsOpen: (open: boolean) => void; 15 | onLogin: (client: MatrixClient, roomAlias: string) => void; 16 | }) => { 17 | const [loginData, setLoginData] = React.useState(); 18 | const [status, setStatus] = React.useState<"ok" | "loading" | "failed">("ok"); 19 | const openDialog = React.useCallback(() => setIsOpen(true), [setIsOpen]); 20 | const closeDialog = React.useCallback(() => { 21 | setIsOpen(false); 22 | }, [setIsOpen]); 23 | 24 | const doLogin = React.useCallback(() => { 25 | setStatus("loading"); 26 | (async () => { 27 | try { 28 | const matrixClient = await createMatrixClient(loginData!); 29 | setIsOpen(false); 30 | onLogin(matrixClient, loginData!.roomAlias); 31 | setStatus("ok"); 32 | } catch (e) { 33 | setStatus("failed"); 34 | } 35 | })(); 36 | }, [setIsOpen, loginData, onLogin]); 37 | 38 | return ( 39 | <> 40 | Sign in with Matrix 41 | {isOpen && ( 42 | 46 | // This is a description of the dialog. 47 | // 48 | // } 49 | renderFooter={(props) => ( 50 | 51 | 52 | Support for OpenID / OAuth is{" "} 53 | 57 | in progress 58 | 59 | . 60 | 61 | 62 | 63 | )} 64 | footerButtons={[ 65 | { 66 | content: "Sign in", 67 | buttonType: "primary", 68 | disabled: status === "loading", 69 | onClick: doLogin, 70 | }, 71 | ]} 72 | onClose={closeDialog}> 73 | 74 | 75 | )} 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | ChoiceInputField, 4 | FormGroup, 5 | InputField, 6 | Radio, 7 | TextInput, 8 | Flash, 9 | } from "@primer/react"; 10 | import React, { useState } from "react"; 11 | import { LoginData } from "./utils"; 12 | 13 | export default function LoginForm({ 14 | setLoginData, 15 | status, 16 | }: { 17 | setLoginData: (data: LoginData) => void; 18 | status: "loading" | "failed" | "ok"; 19 | }) { 20 | const [server, setServer] = useState("https://matrix.org"); 21 | const [user, setUser] = useState("@yousefed:matrix.org"); 22 | const [token, setToken] = useState(""); 23 | const [password, setPassword] = useState(""); 24 | const [roomAlias, setRoomAlias] = useState("#matrix-crdt-test:matrix.org"); 25 | const [authMethod, setAuthMethod] = useState<"password" | "token">( 26 | "password" 27 | ); 28 | const [validationResult, setValidationResult] = useState< 29 | "format" | "prefix" 30 | >(); 31 | 32 | React.useEffect(() => { 33 | if (!/#matrix-crdt-.*/.test(roomAlias)) { 34 | setValidationResult("prefix"); 35 | } else if (!/#.+:.+/.test(roomAlias)) { 36 | setValidationResult("format"); 37 | } else { 38 | setValidationResult(undefined); 39 | } 40 | }, [roomAlias]); 41 | 42 | React.useEffect(() => { 43 | setLoginData({ 44 | server, 45 | user, 46 | token, 47 | password, 48 | roomAlias, 49 | authMethod, 50 | }); 51 | }, [setLoginData, server, user, token, password, roomAlias, authMethod]); 52 | 53 | return ( 54 |
55 | 56 | {status === "failed" && Sign in failed} 57 | 58 | 59 | Homeserver: 60 | setServer(e.target.value)} 62 | defaultValue={server} 63 | /> 64 | 65 | 66 | 67 | 68 | Matrix user id: 69 | setUser(e.target.value)} 71 | defaultValue={user} 72 | placeholder="e.g.: @yousefed:matrix.org" 73 | /> 74 | 75 | 76 |
77 | 78 | 79 | Sign in with password 80 | 81 | setAuthMethod(e.target.value)} 86 | /> 87 | 88 | 89 | 90 | Sign in with Access Token 91 | 92 | setAuthMethod(e.target.value)} 97 | /> 98 | 99 |
100 | {authMethod === "token" && ( 101 | 102 | 103 | Access token: 104 | setToken(e.target.value)} 107 | defaultValue={token} 108 | /> 109 | 110 | You can find your access token in Element Settings -> Help & 111 | About. Your access token is only shared with the Matrix server. 112 | 113 | 114 | 115 | )} 116 | {authMethod === "password" && ( 117 | 118 | 119 | Password: 120 | setPassword(e.target.value)} 124 | defaultValue={password} 125 | /> 126 | 127 | Your password is only shared with the Matrix server. 128 | 129 | 130 | 131 | )} 132 | 133 | 140 | Room alias: 141 | setRoomAlias(e.target.value)} 143 | defaultValue={roomAlias} 144 | placeholder="e.g.: #matrix-crdt-test:matrix.org" 145 | /> 146 | 147 | The room alias must start "#matrix-crdt-" for testing purposes. 148 | 149 | 150 | Room aliases should be of the format #alias:server.tld 151 | 152 | 153 | The room that application state will be synced with. 154 | 155 | 156 | 157 |
158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/login/utils.ts: -------------------------------------------------------------------------------- 1 | import sdk from "matrix-js-sdk"; 2 | 3 | export type LoginData = { 4 | server: string; 5 | user: string; 6 | roomAlias: string; 7 | authMethod: "password" | "token"; 8 | password: string; 9 | token: string; 10 | }; 11 | 12 | export async function createMatrixClient(data: LoginData) { 13 | const signInOpts = { 14 | baseUrl: data.server, 15 | 16 | userId: data.user, 17 | }; 18 | 19 | const matrixClient = 20 | data.authMethod === "token" 21 | ? sdk.createClient({ 22 | ...signInOpts, 23 | accessToken: data.token, 24 | }) 25 | : sdk.createClient(signInOpts); 26 | 27 | if (data.authMethod === "token") { 28 | await matrixClient.loginWithToken(data.token); 29 | } else { 30 | await matrixClient.login("m.login.password", { 31 | user: data.user, 32 | password: data.password, 33 | }); 34 | } 35 | 36 | // overwrites because we don't call .start(); 37 | (matrixClient as any).canSupportVoip = false; 38 | (matrixClient as any).clientOpts = { 39 | lazyLoadMembers: true, 40 | }; 41 | return matrixClient; 42 | } 43 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/todo-simple-react/src/store.ts: -------------------------------------------------------------------------------- 1 | import { syncedStore } from "@syncedstore/core"; 2 | 3 | export type Todo = { 4 | title: string; 5 | completed: boolean; 6 | }; 7 | 8 | export const globalStore = syncedStore({ todos: [] as Todo[] }); 9 | -------------------------------------------------------------------------------- /examples/todo-simple-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "useNx": false, 4 | "useWorkspaces": true, 5 | "version": "0.2.0" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "license": "MPL-2.0", 5 | "workspaces": [ 6 | "packages/*", 7 | "examples/*" 8 | ], 9 | "scripts": { 10 | "install-lerna": "npm install --no-package-lock", 11 | "postinstall": "npm run bootstrap", 12 | "bootstrap": "lerna bootstrap --ci", 13 | "install-new-packages": "lerna bootstrap", 14 | "test": "lerna run --stream --scope matrix-crdt test", 15 | "build": "lerna run --stream build --concurrency 1", 16 | "prepublishOnly": "npm run test && npm run build && cp README.md packages/matrix-crdt/README.md", 17 | "postpublish": "rm -rf packages/matrix-crdt/README.md", 18 | "deploy": "lerna publish", 19 | "redeploy": "lerna publish from-package", 20 | "watch": "lerna run watch" 21 | }, 22 | "devDependencies": { 23 | "lerna": "^5.5.0", 24 | "ts-node": "9.1.1", 25 | "typescript": "^4.4.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/matrix-crdt/.npmrc: -------------------------------------------------------------------------------- 1 | @matrix-org:registry=https://gitlab.matrix.org/api/v4/packages/npm/ 2 | -------------------------------------------------------------------------------- /packages/matrix-crdt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-crdt", 3 | "description": "", 4 | "homepage": "https://github.com/YousefED/matrix-crdt", 5 | "author": { 6 | "name": "Yousef El-Dardiry" 7 | }, 8 | "type": "module", 9 | "version": "0.2.0", 10 | "private": false, 11 | "license": "MPL-2.0", 12 | "dependencies": { 13 | "another-json": "^0.2.0", 14 | "lodash": "^4.17.21", 15 | "simple-peer": "^9.11.0", 16 | "vscode-lib": "^0.1.0" 17 | }, 18 | "devDependencies": { 19 | "@matrix-org/olm": "^3.2.12", 20 | "@peculiar/webcrypto": "^1.1.7", 21 | "@types/autocannon": "4.1.1", 22 | "@types/lodash": "^4.14.178", 23 | "@types/qs": "^6.9.7", 24 | "@types/simple-peer": "^9.11.3", 25 | "autocannon": "7.4.0", 26 | "c8": "^7.12.0", 27 | "cross-fetch": "^3.1.4", 28 | "got": "^11.8.2", 29 | "jest-environment-jsdom": "^28.1.3", 30 | "lib0": "^0.2.42", 31 | "matrix-js-sdk": "^19.4.0", 32 | "qs": "^6.10.2", 33 | "rimraf": "^3.0.2", 34 | "typescript": "^4.4.4", 35 | "vite": "^3.0.0", 36 | "vitest": "^0.20.3", 37 | "y-protocols": "^1.0.5", 38 | "yjs": "^13.5.16" 39 | }, 40 | "peerDependencies": { 41 | "lib0": "*", 42 | "matrix-js-sdk": "*", 43 | "y-protocols": "*", 44 | "yjs": "*" 45 | }, 46 | "files": [ 47 | "/dist", 48 | "/types" 49 | ], 50 | "source": "src/index.ts", 51 | "types": "types/index.d.ts", 52 | "main": "./dist/matrix-crdt.umd.cjs", 53 | "module": "./dist/matrix-crdt.js", 54 | "exports": { 55 | ".": { 56 | "import": "./dist/matrix-crdt.js", 57 | "require": "./dist/matrix-crdt.umd.cjs" 58 | } 59 | }, 60 | "scripts": { 61 | "clean": "rimraf dist && rimraf types", 62 | "build": "npm run clean && tsc && vite build", 63 | "test": "vitest run --coverage", 64 | "watch": "tsc --watch", 65 | "bench": "NODE_OPTIONS='--max-old-space-size=4096' ts-node --files -O '{\"module\":\"commonjs\"}' src/matrix-crdt/benchmark/benchmarkTest.ts " 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/@types/another-json.d.ts: -------------------------------------------------------------------------------- 1 | declare module "another-json" { 2 | export function stringify(obj: any): string; 3 | } 4 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/MatrixCRDTEventTranslator.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient } from "matrix-js-sdk"; 2 | import { MESSAGE_EVENT_TYPE } from "./util/matrixUtil"; 3 | import { encodeBase64 } from "./util/olmlib"; 4 | 5 | const DEFAULT_OPTIONS = { 6 | // set to true to send everything encapsulated in a m.room.message, 7 | // so you can debug rooms easily in element or other matrix clients 8 | updatesAsRegularMessages: false, 9 | updateEventType: "matrix-crdt.doc_update", 10 | snapshotEventType: "matrix-crdt.doc_snapshot", 11 | }; 12 | 13 | export type MatrixCRDTEventTranslatorOptions = Partial; 14 | 15 | /** 16 | * The MatrixCRDTEventTranslator is responsible for writing and reading 17 | * Yjs updates from / to Matrix events. The options determine how to serialize 18 | * Matrix-CRDT updates. 19 | */ 20 | export class MatrixCRDTEventTranslator { 21 | private readonly opts: typeof DEFAULT_OPTIONS; 22 | 23 | public constructor(opts: MatrixCRDTEventTranslatorOptions = {}) { 24 | this.opts = { ...DEFAULT_OPTIONS, ...opts }; 25 | } 26 | 27 | public async sendUpdate( 28 | client: MatrixClient, 29 | roomId: string, 30 | update: Uint8Array 31 | ) { 32 | const encoded = encodeBase64(update); 33 | const content = { 34 | update: encoded, 35 | }; 36 | if (this.opts.updatesAsRegularMessages) { 37 | const wrappedContent = { 38 | body: this.opts.updateEventType + ": " + encoded, 39 | msgtype: this.opts.updateEventType, 40 | ...content, 41 | }; 42 | (client as any).scheduler = undefined; 43 | await client.sendEvent(roomId, MESSAGE_EVENT_TYPE, wrappedContent, ""); 44 | } else { 45 | await client.sendEvent(roomId, this.opts.updateEventType, content, ""); 46 | } 47 | } 48 | 49 | public async sendSnapshot( 50 | client: MatrixClient, 51 | roomId: string, 52 | snapshot: Uint8Array, 53 | lastEventId: string 54 | ) { 55 | const encoded = encodeBase64(snapshot); 56 | const content = { 57 | update: encoded, 58 | last_event_id: lastEventId, 59 | }; 60 | if (this.opts.updatesAsRegularMessages) { 61 | const wrappedContent = { 62 | body: this.opts.snapshotEventType + ": " + encoded, 63 | msgtype: this.opts.snapshotEventType, 64 | ...content, 65 | }; 66 | (client as any).scheduler = undefined; 67 | await client.sendEvent(roomId, MESSAGE_EVENT_TYPE, wrappedContent, ""); 68 | } else { 69 | await client.sendEvent(roomId, this.opts.snapshotEventType, content, ""); 70 | } 71 | } 72 | 73 | public isUpdateEvent(event: any) { 74 | if (this.opts.updatesAsRegularMessages) { 75 | return ( 76 | event.type === MESSAGE_EVENT_TYPE && 77 | event.content.msgtype === this.opts.updateEventType 78 | ); 79 | } 80 | return event.type === this.opts.updateEventType; 81 | } 82 | 83 | public isSnapshotEvent(event: any) { 84 | if (this.opts.updatesAsRegularMessages) { 85 | return ( 86 | event.type === MESSAGE_EVENT_TYPE && 87 | event.content.msgtype === this.opts.snapshotEventType 88 | ); 89 | } 90 | return event.type === this.opts.snapshotEventType; 91 | } 92 | 93 | public get WrappedEventType() { 94 | if (this.opts.updatesAsRegularMessages) { 95 | return MESSAGE_EVENT_TYPE; 96 | } else { 97 | return this.opts.updateEventType; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/MatrixProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, expect, it } from "vitest"; 2 | import { event } from "vscode-lib"; 3 | import * as Y from "yjs"; 4 | import { MatrixProvider } from "./MatrixProvider"; 5 | import { createMatrixGuestClient } from "./test-utils/matrixGuestClient"; 6 | import { 7 | createRandomMatrixClient, 8 | createRandomMatrixClientAndRoom, 9 | initMatrixSDK, 10 | } from "./test-utils/matrixTestUtil"; 11 | import { 12 | ensureMatrixIsRunning, 13 | HOMESERVER_NAME, 14 | matrixTestConfig, 15 | } from "./test-utils/matrixTestUtilServer"; 16 | 17 | beforeAll(async () => { 18 | initMatrixSDK(); 19 | await ensureMatrixIsRunning(); 20 | }); 21 | 22 | type UnPromisify = T extends Promise ? U : T; 23 | 24 | async function getRoomAndTwoUsers(opts: { 25 | bobIsGuest: boolean; 26 | roomAccess: "public-read-write" | "public-read"; 27 | }) { 28 | const setup = await createRandomMatrixClientAndRoom(opts.roomAccess); 29 | const doc = new Y.Doc(); 30 | const provider = new MatrixProvider(doc, setup.client, { 31 | type: "alias", 32 | alias: "#" + setup.roomName + ":" + HOMESERVER_NAME, 33 | }); 34 | 35 | const client2 = opts.bobIsGuest 36 | ? await createMatrixGuestClient(matrixTestConfig) 37 | : (await createRandomMatrixClient()).client; 38 | const doc2 = new Y.Doc(); 39 | const provider2 = new MatrixProvider(doc2, client2, { 40 | type: "alias", 41 | alias: "#" + setup.roomName + ":" + HOMESERVER_NAME, 42 | }); 43 | 44 | return { 45 | alice: { 46 | doc, 47 | provider, 48 | client: setup.client, 49 | }, 50 | bob: { 51 | doc: doc2, 52 | provider: provider2, 53 | client: client2, 54 | }, 55 | }; 56 | } 57 | 58 | async function validateOneWaySync( 59 | users: UnPromisify> 60 | ) { 61 | const { alice, bob } = users; 62 | alice.doc.getMap("test").set("contents", new Y.Text("hello")); 63 | 64 | alice.provider.initialize(); 65 | await alice.provider.waitForFlush(); 66 | await new Promise((resolve) => setTimeout(resolve, 200)); 67 | bob.provider.initialize(); 68 | 69 | // validate initial state 70 | await event.Event.toPromise(bob.provider.onDocumentAvailable); 71 | expect((bob.doc.getMap("test").get("contents") as any).toJSON()).toEqual( 72 | "hello" 73 | ); 74 | expect(bob.doc.getMap("test2")).toBeUndefined; 75 | 76 | // send an update from provider and validate sync 77 | console.log("Alice sending change", alice.client.credentials.userId); 78 | alice.doc.getMap("test2").set("key", 1); 79 | await alice.provider.waitForFlush(); 80 | await event.Event.toPromise(bob.provider.onReceivedEvents); 81 | expect(bob.doc.getMap("test2").get("key")).toBe(1); 82 | 83 | // validate bob.provider is a read-only client (because it's a guestclient) 84 | expect(bob.provider.canWrite).toBe(true); 85 | bob.doc.getMap("test3").set("key", 1); 86 | await new Promise((resolve) => setTimeout(resolve, 2000)); 87 | expect(alice.doc.getMap("test3").get("key")).toBeUndefined; 88 | expect(bob.provider.canWrite).toBe(false); 89 | 90 | alice.provider.dispose(); 91 | bob.provider.dispose(); 92 | } 93 | 94 | async function validateTwoWaySync( 95 | users: UnPromisify> 96 | ) { 97 | const { alice, bob } = users; 98 | alice.doc.getMap("test").set("contents", new Y.Text("hello")); 99 | 100 | alice.provider.initialize(); 101 | await alice.provider.waitForFlush(); 102 | await new Promise((resolve) => setTimeout(resolve, 200)); 103 | bob.provider.initialize(); 104 | 105 | // validate initial state 106 | await event.Event.toPromise(bob.provider.onDocumentAvailable); 107 | expect((bob.doc.getMap("test").get("contents") as any).toJSON()).toEqual( 108 | "hello" 109 | ); 110 | expect(bob.doc.getMap("test2")).toBeUndefined; 111 | 112 | // send an update from provider and validate sync 113 | console.log("Alice sending change", alice.client.credentials.userId); 114 | alice.doc.getMap("test2").set("key", 1); 115 | await alice.provider.waitForFlush(); 116 | await event.Event.toPromise(bob.provider.onReceivedEvents); 117 | expect(bob.doc.getMap("test2").get("key")).toBe(1); 118 | 119 | // validate bob can write 120 | console.log("Bob sending change", bob.client.credentials.userId); 121 | expect(bob.provider.canWrite).toBe(true); 122 | bob.doc.getMap("test3").set("key", 1); 123 | await bob.provider.waitForFlush(); 124 | await event.Event.toPromise(alice.provider.onReceivedEvents); 125 | expect(alice.doc.getMap("test3").get("key")).toBe(1); 126 | expect(bob.provider.canWrite).toBe(true); 127 | 128 | alice.provider.dispose(); 129 | bob.provider.dispose(); 130 | } 131 | 132 | it("syncs public room guest", async () => { 133 | const users = await getRoomAndTwoUsers({ 134 | bobIsGuest: true, 135 | roomAccess: "public-read-write", 136 | }); 137 | await validateOneWaySync(users); 138 | }, 30000); 139 | 140 | it("syncs write-only access", async () => { 141 | const users = await getRoomAndTwoUsers({ 142 | bobIsGuest: false, 143 | roomAccess: "public-read", 144 | }); 145 | await validateOneWaySync(users); 146 | }, 30000); 147 | 148 | it("syncs two users writing ", async () => { 149 | const users = await getRoomAndTwoUsers({ 150 | bobIsGuest: false, 151 | roomAccess: "public-read-write", 152 | }); 153 | await validateTwoWaySync(users); 154 | }, 30000); 155 | 156 | it("syncs with intermediate snapshots ", async () => { 157 | const users = await getRoomAndTwoUsers({ 158 | bobIsGuest: false, 159 | roomAccess: "public-read-write", 160 | }); 161 | 162 | const { alice, bob } = users; 163 | 164 | const text = new Y.Text("hello"); 165 | alice.doc.getMap("test").set("contents", text); 166 | 167 | await alice.provider.initialize(); 168 | 169 | for (let i = 0; i < 100; i++) { 170 | text.insert(text.length, "-" + i); 171 | await alice.provider.waitForFlush(); 172 | } 173 | await new Promise((resolve) => setTimeout(resolve, 2000)); 174 | await bob.provider.initialize(); 175 | 176 | const val = bob.doc.getMap("test").get("contents") as any; 177 | expect(val.toJSON()).toEqual(text.toJSON()); 178 | expect(bob.provider.totalEventsReceived).toBeLessThan(20); 179 | 180 | alice.provider.dispose(); 181 | bob.provider.dispose(); 182 | }, 30000); 183 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/SignedWebrtcProvider.ts: -------------------------------------------------------------------------------- 1 | import * as decoding from "lib0/decoding"; 2 | import * as encoding from "lib0/encoding"; 3 | 4 | import * as awarenessProtocol from "y-protocols/awareness"; 5 | import * as syncProtocol from "y-protocols/sync"; 6 | import * as Y from "yjs"; // eslint-disable-line 7 | import * as logging from "lib0/logging"; 8 | import { WebrtcProvider } from "./webrtc/WebrtcProvider"; 9 | import { globalRooms } from "./webrtc/globalResources"; 10 | import { decodeBase64, encodeBase64 } from "./util/olmlib"; 11 | 12 | const log = logging.createModuleLogger("y-webrtc"); 13 | 14 | export const messageSync = 0; 15 | export const messageQueryAwareness = 3; 16 | export const messageAwareness = 1; 17 | 18 | /** 19 | * This class implements a webrtc+broadcast channel for document updates and awareness, 20 | * with signed messages which should be verified. 21 | * 22 | * We use SignedWebrtcProvider to establish a "live connection" between peers, 23 | * so that changes by simultaneous editors are synced instantly. 24 | * 25 | * Ideally, we'd just send these over Matrix as Ephemeral events, 26 | * but custom ephemeral events are not supported yet. 27 | * 28 | * This implementation mimicks the original y-webrtc implementation. However: 29 | * - initial document state is not synced via this provider, only incremental updates 30 | * 31 | * We should probably move to ephemeral messages when that's available. Besides from that, 32 | * the following improvements can be made: 33 | * - Support a TURN server in case clients can't connect directly over WebRTC 34 | * - Do the signalling over Matrix, instead of the original yjs websocket server 35 | * - Verify the webrtc connection instead of signing / verifying every message 36 | * - It would be better to not depend this class on yjs (remove dependency on Y.Doc and Awareness) 37 | * Instead, design it in a way that it's just a different transport for "Matrix events". 38 | * This would also fix the following issue: 39 | * 40 | * Issue (non-breaking): 41 | * - The original y-webrtc is designed so that document updates are send to all peers, by all peers. 42 | * This means that if A, B, C, and D are connected, when A issues an update and B receives it, 43 | * B will issue that same update (because it triggers a y.doc.update) and send it to all peers as well 44 | * (even though most peers would have received it already). 45 | * What's not cool about our implementation now is that this forward-syncing goes via the Y.Doc, 46 | * i.e.: client B will create a new update and sign it itself. Which means that if B is a read-only 47 | * client, all other clients will receive "invalid" messages (which could have been prevented if 48 | * the message from A was forwarded directly) 49 | * 50 | * Issue (edge-case): 51 | * - if an update is received and validated by B, and A hasn't synced it to Matrix yet, 52 | * then B will sync it to Matrix upon restart (when it syncs local changes to Matrix). 53 | * This will cause an update originally from A to be sent to Matrix as authored by B 54 | */ 55 | export class SignedWebrtcProvider extends WebrtcProvider { 56 | protected onCustomMessage = ( 57 | buf: Uint8Array, 58 | reply: (message: Uint8Array) => void 59 | ): void => { 60 | const decoder = decoding.createDecoder(buf); 61 | const encoder = encoding.createEncoder(); 62 | 63 | const messageType = decoding.readVarUint(decoder); 64 | 65 | switch (messageType) { 66 | case messageSync: { 67 | const strMessage = decoding.readAny(decoder); 68 | this.verify(strMessage).then( 69 | () => { 70 | const update = decodeBase64(strMessage.message); 71 | const decoder2 = decoding.createDecoder(update); 72 | const syncMessageType = syncProtocol.readSyncMessage( 73 | decoder2, 74 | encoder, 75 | this.doc, 76 | this 77 | ); 78 | if (syncMessageType !== syncProtocol.messageYjsUpdate) { 79 | log("error: expect only updates"); 80 | throw new Error("error: only update messages expected"); 81 | } 82 | }, 83 | (err) => { 84 | console.error("couldn't verify message"); 85 | } 86 | ); 87 | break; 88 | } 89 | case messageAwareness: 90 | awarenessProtocol.applyAwarenessUpdate( 91 | this.awareness, 92 | decoding.readVarUint8Array(decoder), 93 | this 94 | ); 95 | break; 96 | } 97 | return undefined; 98 | }; 99 | 100 | protected onPeerConnected = (reply: (message: Uint8Array) => void): void => { 101 | // awareness 102 | const encoder = encoding.createEncoder(); 103 | const awarenessStates = this.awareness.getStates(); 104 | if (awarenessStates.size > 0) { 105 | encoding.writeVarUint(encoder, messageAwareness); 106 | encoding.writeVarUint8Array( 107 | encoder, 108 | awarenessProtocol.encodeAwarenessUpdate( 109 | this.awareness, 110 | Array.from(awarenessStates.keys()) 111 | ) 112 | ); 113 | reply(encoding.toUint8Array(encoder)); 114 | } 115 | }; 116 | 117 | /** 118 | * Listens to Yjs updates and sends them to remote peers 119 | */ 120 | private _docUpdateHandler = async (update: Uint8Array, origin: any) => { 121 | if (!this.room) { 122 | return; 123 | } 124 | const encoder = encoding.createEncoder(); 125 | encoding.writeVarUint(encoder, messageSync); 126 | 127 | const syncEncoder = encoding.createEncoder(); 128 | syncProtocol.writeUpdate(syncEncoder, update); 129 | 130 | const obj = { 131 | message: encodeBase64(encoding.toUint8Array(syncEncoder)), 132 | }; 133 | 134 | await this.sign(obj); 135 | 136 | encoding.writeAny(encoder, obj); 137 | this.room.broadcastRoomMessage(encoding.toUint8Array(encoder)); 138 | }; 139 | 140 | /** 141 | * Listens to Awareness updates and sends them to remote peers 142 | */ 143 | private _awarenessUpdateHandler = ( 144 | { added, updated, removed }: any, 145 | origin: any 146 | ) => { 147 | if (!this.room) { 148 | return; 149 | } 150 | 151 | const changedClients = added.concat(updated).concat(removed); 152 | log( 153 | "awareness change ", 154 | { added, updated, removed }, 155 | "local", 156 | this.awareness.clientID 157 | ); 158 | 159 | const encoderAwareness = encoding.createEncoder(); 160 | encoding.writeVarUint(encoderAwareness, messageAwareness); 161 | encoding.writeVarUint8Array( 162 | encoderAwareness, 163 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients) 164 | ); 165 | this.room.broadcastRoomMessage(encoding.toUint8Array(encoderAwareness)); 166 | }; 167 | 168 | public constructor( 169 | private doc: Y.Doc, 170 | roomName: string, 171 | private roomPassword: string, 172 | private sign: (obj: any) => Promise, 173 | private verify: (obj: any) => Promise, 174 | opts?: any, 175 | public readonly awareness = new awarenessProtocol.Awareness(doc) 176 | ) { 177 | super(roomName, { password: roomPassword, ...opts }); 178 | 179 | doc.on("destroy", this.destroy.bind(this)); 180 | 181 | this.doc.on("update", this._docUpdateHandler); 182 | this.awareness.on("update", this._awarenessUpdateHandler); 183 | 184 | window.addEventListener("beforeunload", () => { 185 | awarenessProtocol.removeAwarenessStates( 186 | this.awareness, 187 | [doc.clientID], 188 | "window unload" 189 | ); 190 | globalRooms.forEach((room) => { 191 | room.disconnect(); 192 | }); 193 | }); 194 | } 195 | 196 | destroy() { 197 | this.doc.off("update", this._docUpdateHandler); 198 | this.awareness.off("update", this._awarenessUpdateHandler); 199 | this.doc.off("destroy", this.destroy); 200 | super.destroy(); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/benchmark/README.md: -------------------------------------------------------------------------------- 1 | - 1 room, read history, wait until doc is loaded. Many users in parallel 2 | - x rooms, 1 reader user, 1 writer user 3 | - long polling 4 | 5 | ### Possible libraries: 6 | 7 | - https://github.com/mcollina/autocannon 8 | - https://github.com/bestiejs/benchmark.js 9 | - https://github.com/matteofigus/api-benchmark 10 | - jmeter 11 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/benchmark/benchmarkTest.ts: -------------------------------------------------------------------------------- 1 | import fetch from "cross-fetch"; 2 | import * as http from "http"; 3 | import { 4 | autocannonSeparateProcess, 5 | createSimpleServer, 6 | runAutocannonFromNode, 7 | } from "./util"; 8 | 9 | // In this file, we compare different ways of benchmarking. 10 | // This can be used to estimate the overhead of our benchmarking process 11 | // We use the Matrix home page url as test (which is a static page, so Synapse, the Matrix server, should process it quickly) 12 | 13 | http.globalAgent.maxSockets = 20000; 14 | 15 | const MATRIX_HOME_URL = new URL("http://localhost:8888/_matrix/static/"); 16 | const NODE_SERVER_PORT = 8080; 17 | 18 | /** 19 | * - Call Autocannon from Node 20 | * - Autocannon requests a page from a local node server 21 | * - This node server requests the test page using Fetch 22 | */ 23 | async function autocannonViaServerFetch() { 24 | console.log("autocannonViaServerFetch"); 25 | const server = await createSimpleServer(async (req, res) => { 26 | const result = await fetch(MATRIX_HOME_URL.toString()); 27 | // const ret = await result.text(); 28 | // if (!ret.includes("Welcome to the Matrix")) { 29 | // throw new Error("unexpected result"); 30 | // } 31 | }, NODE_SERVER_PORT); 32 | await runAutocannonFromNode("http://localhost:" + NODE_SERVER_PORT); 33 | server.close(); 34 | } 35 | 36 | /** 37 | * - Call Autocannon from Node 38 | * - Autocannon requests a page from a local node server 39 | * - This node server requests the test page using Fetch, with a customized Agent 40 | */ 41 | async function autocannonViaServerFetchAgent() { 42 | console.log("autocannonViaServerFetchAgent"); 43 | const httpAgent = new http.Agent({ 44 | keepAlive: true, 45 | maxSockets: 100000, 46 | }); 47 | 48 | const server = await createSimpleServer(async (req, res) => { 49 | const result = await fetch(MATRIX_HOME_URL.toString(), { 50 | agent: function () { 51 | return httpAgent; 52 | }, 53 | } as any); 54 | 55 | // const ret = await result.text(); 56 | // if (!ret.includes("Welcome to the Matrix")) { 57 | // throw new Error("unexpected result"); 58 | // } 59 | }, NODE_SERVER_PORT); 60 | await runAutocannonFromNode("http://localhost:" + NODE_SERVER_PORT); 61 | server.close(); 62 | } 63 | 64 | /** 65 | * - Call Autocannon from Node 66 | * - Autocannon requests a page from a local node server 67 | * - This node server requests the test page using http.get, without agent 68 | */ 69 | async function autocannonViaServerHttpNoAgent() { 70 | console.log("autocannonViaServerHttpNoAgent"); 71 | const server = await createSimpleServer(async (req, res) => { 72 | await new Promise((resolve) => { 73 | http.get( 74 | { 75 | hostname: MATRIX_HOME_URL.hostname, 76 | port: MATRIX_HOME_URL.port, 77 | path: MATRIX_HOME_URL.pathname, 78 | agent: false, // Create a new agent just for this one request 79 | }, 80 | (res) => { 81 | // Do stuff with response 82 | resolve(); 83 | } 84 | ); 85 | }); 86 | }, NODE_SERVER_PORT); 87 | await runAutocannonFromNode("http://localhost:" + NODE_SERVER_PORT); 88 | server.close(); 89 | } 90 | 91 | /** 92 | * - Call Autocannon from Node 93 | * - Autocannon requests a page from a local node server 94 | * - This node server requests the test page using http.get 95 | */ 96 | async function autocannonViaServerHttp() { 97 | console.log("autocannonViaServerHttp"); 98 | const server = await createSimpleServer(async (req, res) => { 99 | await new Promise((resolve) => { 100 | http.get( 101 | { 102 | hostname: MATRIX_HOME_URL.hostname, 103 | port: MATRIX_HOME_URL.port, 104 | path: MATRIX_HOME_URL.pathname, 105 | }, 106 | (res) => { 107 | // Do stuff with response 108 | resolve(); 109 | } 110 | ); 111 | }); 112 | }, NODE_SERVER_PORT); 113 | await runAutocannonFromNode("http://localhost:" + NODE_SERVER_PORT); 114 | server.close(); 115 | } 116 | 117 | /** 118 | * - Call Autocannon from Node 119 | * - Autocannon requests the test page directly 120 | */ 121 | async function autocannonFromNode() { 122 | console.log("autocannonFromNode"); 123 | await runAutocannonFromNode(MATRIX_HOME_URL.toString()); 124 | } 125 | 126 | /** 127 | * - Call autocannon in a separate process, which calls the test page directly 128 | */ 129 | async function autocannonHomeSeparateProcess() { 130 | console.log("autocannonHomeSeparateProcess"); 131 | 132 | await autocannonSeparateProcess(["-c", "10", MATRIX_HOME_URL.toString()]); 133 | } 134 | 135 | async function runAllTests() { 136 | await autocannonHomeSeparateProcess(); 137 | await autocannonFromNode(); 138 | await autocannonViaServerHttp(); 139 | await autocannonViaServerHttpNoAgent(); 140 | await autocannonViaServerFetch(); 141 | await autocannonViaServerFetchAgent(); 142 | } 143 | runAllTests(); 144 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/benchmark/matrix.bench.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import * as https from "https"; 3 | import { MatrixClient } from "matrix-js-sdk"; 4 | import * as Y from "yjs"; 5 | import { event } from "vscode-lib"; 6 | import { createMatrixGuestClient } from "../test-utils/matrixGuestClient"; 7 | import { MatrixProvider } from "../MatrixProvider"; 8 | import { createRandomMatrixClientAndRoom } from "../test-utils/matrixTestUtil"; 9 | import { 10 | HOMESERVER_NAME, 11 | matrixTestConfig, 12 | } from "../test-utils/matrixTestUtilServer"; 13 | import { createSimpleServer, runAutocannonFromNode } from "./util"; 14 | http.globalAgent.maxSockets = 2000; 15 | https.globalAgent.maxSockets = 2000; 16 | 17 | async function setRoomContents(client: MatrixClient, roomName: string) { 18 | const doc = new Y.Doc(); 19 | doc.getMap("test").set("contents", new Y.Text("hello")); 20 | const provider = new MatrixProvider(doc, client, { 21 | type: "alias", 22 | alias: "#" + roomName + ":" + HOMESERVER_NAME, 23 | }); 24 | provider.initialize(); 25 | await provider.waitForFlush(); 26 | } 27 | 28 | let client: any; 29 | async function readRoom(roomName: string) { 30 | if (!client) { 31 | client = await createMatrixGuestClient(matrixTestConfig); 32 | } 33 | const doc = new Y.Doc(); 34 | const provider = new MatrixProvider(doc, client, { 35 | type: "alias", 36 | alias: "#" + roomName + ":" + HOMESERVER_NAME, 37 | }); 38 | provider.initialize(); 39 | await event.Event.toPromise(provider.onDocumentAvailable); 40 | const text = doc.getMap("test").get("contents") as Y.Text; 41 | if (text.toJSON() !== "hello") { 42 | throw new Error("invalid contents of ydoc"); 43 | } 44 | provider.dispose(); 45 | } 46 | 47 | async function loadTest() { 48 | const setup = await createRandomMatrixClientAndRoom("public-read-write"); 49 | 50 | await setRoomContents(setup.client, setup.roomId); 51 | const server = await createSimpleServer(() => readRoom(setup.roomName)); 52 | await runAutocannonFromNode("http://localhost:8080"); 53 | server.close(); 54 | } 55 | 56 | // it("basic replace", async () => { 57 | // // const testId = Math.round(Math.random() * 10000); 58 | // // const username = "testuser_" + testId; 59 | // // const roomName = "@" + username + "/test"; 60 | 61 | // // await createRoom(username, roomName); 62 | // // await readRoom(roomName); 63 | // await loadTest(); 64 | // }, 30000); 65 | loadTest(); 66 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/benchmark/util.ts: -------------------------------------------------------------------------------- 1 | import autocannon from "autocannon"; 2 | import * as http from "http"; 3 | import * as cp from "child_process"; 4 | 5 | export async function createSimpleServer( 6 | handler: (req: any, res: any) => Promise, 7 | port = 8080 8 | ) { 9 | const requestListener = async function (req: any, res: any) { 10 | try { 11 | await handler(req, res); 12 | res.writeHead(200); 13 | res.end("Success!"); 14 | } catch (e) { 15 | console.log(e); 16 | res.writeHead(500); 17 | res.end("Error"); 18 | } 19 | }; 20 | 21 | const server = http.createServer(requestListener); 22 | server.maxConnections = 10000; 23 | server.listen(port); 24 | return server; 25 | } 26 | 27 | export async function runAutocannonFromNode(url: string) { 28 | const result = await autocannon({ 29 | url, 30 | connections: 10, // default 31 | pipelining: 1, // default 32 | duration: 10, // default 33 | }); 34 | const ret = (autocannon as any).printResult(result, { 35 | renderLatencyTable: false, 36 | renderResultsTable: true, 37 | }); 38 | console.log(ret); 39 | } 40 | 41 | export async function autocannonSeparateProcess(params: string[]) { 42 | console.log("autocannonSeparateProcess"); 43 | 44 | const ls = cp.spawn("./node_modules/.bin/autocannon", params); 45 | return new Promise((resolve) => { 46 | ls.stdout.on("data", (data: any) => { 47 | console.log(`stdout: ${data}`); 48 | }); 49 | 50 | ls.stderr.on("data", (data: any) => { 51 | console.log(`stderr: ${data}`); 52 | }); 53 | 54 | ls.on("close", (code: any) => { 55 | console.log(`child process exited with code ${code}`); 56 | resolve(); 57 | }); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MatrixProvider"; 2 | export * from "./matrixRoomManagement"; 3 | export * from "./webrtc/DocWebrtcProvider"; 4 | export * from "./memberReader/MatrixMemberReader"; 5 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/matrixRoomManagement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to create a Matrix room suitable for use with MatrixProvider. 3 | * Access can currently be set to "public-read-write" | "public-read" 4 | */ 5 | export async function createMatrixRoom( 6 | matrixClient: any, 7 | roomName: string, 8 | access: "public-read-write" | "public-read" 9 | ) { 10 | try { 11 | const initial_state = []; 12 | 13 | // guests should not be able to actually join the room, 14 | // because we don't want guests to be able to write 15 | initial_state.push({ 16 | type: "m.room.guest_access", 17 | state_key: "", 18 | content: { 19 | guest_access: "forbidden", 20 | }, 21 | }); 22 | 23 | // if there is no public write access, make sure to set 24 | // join_rule to invite 25 | initial_state.push({ 26 | type: "m.room.join_rules", 27 | content: { 28 | join_rule: access === "public-read-write" ? "public" : "invite", 29 | }, 30 | }); 31 | 32 | // The history of a (publicly accessible) room should be readable by everyone, 33 | // so that all users can get all yjs updates 34 | initial_state.push({ 35 | type: "m.room.history_visibility", 36 | content: { 37 | history_visibility: "world_readable", 38 | }, 39 | }); 40 | 41 | // for e2ee 42 | // initial_state.push({ 43 | // type: "m.room.encryption", 44 | // state_key: "", 45 | // content: { 46 | // algorithm: "m.megolm.v1.aes-sha2", 47 | // }, 48 | // }); 49 | 50 | const ret = await matrixClient.createRoom({ 51 | room_alias_name: roomName, 52 | visibility: "public", // Whether this room is visible to the /publicRooms API or not." One of: ["private", "public"] 53 | name: roomName, 54 | topic: "", 55 | initial_state, 56 | }); 57 | 58 | // TODO: add room to space 59 | 60 | return { status: "ok" as "ok", roomId: ret.room_id }; 61 | } catch (e: any) { 62 | if (e.errcode === "M_ROOM_IN_USE") { 63 | return "already-exists" as "already-exists"; 64 | } 65 | if (e.name === "ConnectionError") { 66 | return "offline"; 67 | } 68 | 69 | return { 70 | status: "error" as "error", 71 | error: e, 72 | }; 73 | // offline error? 74 | } 75 | } 76 | 77 | export async function getMatrixRoomAccess(matrixClient: any, roomId: string) { 78 | let result: any; 79 | 80 | try { 81 | result = await matrixClient.getStateEvent(roomId, "m.room.join_rules"); 82 | } catch (e) { 83 | return { 84 | status: "error" as "error", 85 | error: e, 86 | }; 87 | } 88 | 89 | if (result.join_rule === "public") { 90 | return "public-read-write"; 91 | } else if (result.join_rule === "invite") { 92 | return "public-read"; 93 | } else { 94 | throw new Error("unsupported join_rule"); 95 | } 96 | } 97 | 98 | /** 99 | * Helper function to change access of a Matrix Room 100 | * Access can currently be set to "public-read-write" | "public-read" 101 | */ 102 | export async function updateMatrixRoomAccess( 103 | matrixClient: any, 104 | roomId: string, 105 | access: "public-read-write" | "public-read" 106 | ) { 107 | try { 108 | await matrixClient.sendStateEvent( 109 | roomId, 110 | "m.room.join_rules", 111 | { join_rule: access === "public-read-write" ? "public" : "invite" }, 112 | "" 113 | ); 114 | 115 | // TODO: add room to space 116 | 117 | return { status: "ok" as "ok", roomId }; 118 | } catch (e: any) { 119 | if (e.name === "ConnectionError") { 120 | return "offline"; 121 | } 122 | 123 | return { 124 | status: "error" as "error", 125 | error: e, 126 | }; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/memberReader/MatrixMemberReader.test.ts: -------------------------------------------------------------------------------- 1 | import { MatrixEvent } from "matrix-js-sdk"; 2 | import { beforeAll, expect, it } from "vitest"; 3 | import { MatrixCRDTEventTranslator } from "../MatrixCRDTEventTranslator"; 4 | import { MatrixReader } from "../reader/MatrixReader"; 5 | import { createMatrixGuestClient } from "../test-utils/matrixGuestClient"; 6 | import { 7 | createRandomMatrixClient, 8 | createRandomMatrixClientAndRoom, 9 | initMatrixSDK, 10 | } from "../test-utils/matrixTestUtil"; 11 | import { 12 | ensureMatrixIsRunning, 13 | matrixTestConfig, 14 | } from "../test-utils/matrixTestUtilServer"; 15 | import { MatrixMemberReader } from "./MatrixMemberReader"; 16 | 17 | beforeAll(async () => { 18 | initMatrixSDK(); 19 | await ensureMatrixIsRunning(); 20 | }); 21 | 22 | it("handles room joins", async () => { 23 | const setupA = await createRandomMatrixClientAndRoom("public-read-write"); 24 | const userB = await createRandomMatrixClient(); 25 | const guestClient = await createMatrixGuestClient(matrixTestConfig); 26 | 27 | const readerC = new MatrixReader( 28 | guestClient, 29 | setupA.roomId, 30 | new MatrixCRDTEventTranslator() 31 | ); 32 | const memberC = new MatrixMemberReader(guestClient, readerC); 33 | await readerC.getInitialDocumentUpdateEvents(); 34 | await readerC.startPolling(); 35 | await memberC.initialize(); 36 | 37 | expect(memberC.hasWriteAccess(guestClient.credentials.userId!)).toBe(false); 38 | expect(memberC.hasWriteAccess(setupA.client.credentials.userId!)).toBe(true); 39 | expect(memberC.hasWriteAccess(userB.client.credentials.userId!)).toBe(false); 40 | 41 | await userB.client.joinRoom(setupA.roomId); 42 | await new Promise((resolve) => setTimeout(resolve, 1500)); 43 | 44 | expect(memberC.hasWriteAccess(userB.client.credentials.userId!)).toBe(true); 45 | 46 | readerC.dispose(); 47 | }, 30000); 48 | 49 | it("handles room power levels", async () => { 50 | const setupA = await createRandomMatrixClientAndRoom("public-read-write"); 51 | const userB = await createRandomMatrixClient(); 52 | const guestClient = await createMatrixGuestClient(matrixTestConfig); 53 | 54 | const readerC = new MatrixReader( 55 | guestClient, 56 | setupA.roomId, 57 | new MatrixCRDTEventTranslator() 58 | ); 59 | const memberC = new MatrixMemberReader(guestClient, readerC); 60 | await readerC.getInitialDocumentUpdateEvents(); 61 | await readerC.startPolling(); 62 | await memberC.initialize(); 63 | 64 | await userB.client.joinRoom(setupA.roomId); 65 | 66 | await new Promise((resolve) => setTimeout(resolve, 1500)); 67 | expect(memberC.hasWriteAccess(setupA.client.credentials.userId!)).toBe(true); 68 | expect(memberC.hasWriteAccess(userB.client.credentials.userId!)).toBe(true); 69 | 70 | let levels = await setupA.client.getStateEvent( 71 | setupA.roomId, 72 | "m.room.power_levels", 73 | undefined as any 74 | ); 75 | 76 | levels.events_default = 40; 77 | await setupA.client.sendStateEvent( 78 | setupA.roomId, 79 | "m.room.power_levels", 80 | levels 81 | ); 82 | 83 | await new Promise((resolve) => setTimeout(resolve, 1500)); 84 | expect(memberC.hasWriteAccess(setupA.client.credentials.userId!)).toBe(true); 85 | expect(memberC.hasWriteAccess(userB.client.credentials.userId!)).toBe(false); 86 | 87 | await setupA.client.setPowerLevel( 88 | setupA.roomId, 89 | userB.client.credentials.userId!, 90 | 50, 91 | new MatrixEvent({ content: levels, type: "m.room.power_levels" }) 92 | ); 93 | 94 | await new Promise((resolve) => setTimeout(resolve, 1500)); 95 | expect(memberC.hasWriteAccess(setupA.client.credentials.userId!)).toBe(true); 96 | expect(memberC.hasWriteAccess(userB.client.credentials.userId!)).toBe(true); 97 | 98 | readerC.dispose(); 99 | }, 30000); 100 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/memberReader/MatrixMemberReader.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient } from "matrix-js-sdk"; 2 | import { lifecycle } from "vscode-lib"; 3 | import { MatrixReader } from "../reader/MatrixReader"; 4 | 5 | type Member = { 6 | displayname: string; 7 | user_id: string; 8 | }; 9 | 10 | /** 11 | * TODO: possible to replace with matrixClient maySendMessage / maySendEvent? 12 | * 13 | * Keeps track of Members in a room with write access 14 | * 15 | * Use hasWriteAccess to validate whether a user has write access to the room. 16 | * 17 | * A MatrixMemberReader keeps track of users and permissions by 18 | * retrieving and monitoring m.room.member and m.room.power_levels information 19 | */ 20 | export class MatrixMemberReader extends lifecycle.Disposable { 21 | private disposed = false; 22 | private initialized = false; 23 | private initializing = false; 24 | private initializeOutdated = false; 25 | public readonly members: Map = new Map(); 26 | private powerLevels: 27 | | { 28 | events: { [event_type: string]: number }; 29 | events_default: number; 30 | users: { [user_id: string]: number }; 31 | users_default: number; 32 | } 33 | | undefined; 34 | 35 | public constructor( 36 | private matrixClient: MatrixClient, 37 | private reader: MatrixReader 38 | ) { 39 | super(); 40 | this._register( 41 | this.reader.onEvents((e) => e.events.forEach((e) => this.processEvent(e))) 42 | ); 43 | } 44 | 45 | public hasWriteAccess(user_id: string, event_type = "m.room.message") { 46 | if (!this.members.has(user_id)) { 47 | return false; 48 | } 49 | const levels = this.powerLevels!; 50 | 51 | let requiredLevel = levels.events[event_type]; 52 | if (requiredLevel === undefined) { 53 | requiredLevel = levels.events_default; 54 | } 55 | 56 | let userLevel = levels.users[user_id]; 57 | if (userLevel === undefined) { 58 | userLevel = levels.users_default; 59 | } 60 | if (typeof userLevel !== "number" || typeof requiredLevel !== "number") { 61 | throw new Error("unexpected"); 62 | } 63 | return userLevel >= requiredLevel; 64 | } 65 | 66 | private processEvent = (event: any) => { 67 | if ( 68 | event.type !== "m.room.power_levels" && 69 | event.type !== "m.room.member" 70 | ) { 71 | return; 72 | } 73 | 74 | if (this.initializing) { 75 | this.initializeOutdated = true; 76 | return; 77 | } 78 | 79 | if (!this.initialized) { 80 | return; 81 | } 82 | 83 | if (event.type === "m.room.power_levels") { 84 | this.powerLevels = event.content; 85 | // TODO: test 86 | return; 87 | } 88 | if (event.type === "m.room.member") { 89 | if ( 90 | event.content.membership === "join" || 91 | event.content.membership === "invite" 92 | ) { 93 | const member: Member = { 94 | displayname: event.content.displayname, 95 | user_id: event.state_key, 96 | }; 97 | this.members.set(event.state_key, member); 98 | } else { 99 | this.members.delete(event.state_key); 100 | } 101 | return; 102 | } 103 | 104 | throw new Error("unexpected"); 105 | }; 106 | 107 | public async initialize(): Promise { 108 | if (this.initializing || this.initialized) { 109 | throw new Error("already initializing / initialized"); 110 | } 111 | 112 | if (!this.reader.isStarted) { 113 | throw new Error( 114 | "MatrixReader must have started before initializing MatrixMemberReader" 115 | ); 116 | } 117 | 118 | this.initializing = true; 119 | const [powerLevels, members] = await Promise.all([ 120 | this.matrixClient.getStateEvent( 121 | this.reader.roomId, 122 | "m.room.power_levels", 123 | undefined as any 124 | ), 125 | this.matrixClient.members( 126 | this.reader.roomId, 127 | undefined, 128 | ["knock", "leave", "ban"] as any // any because of https://github.com/matrix-org/matrix-js-sdk/pull/2319 129 | ), 130 | ]); 131 | if (this.initializeOutdated) { 132 | // A power_levels or member event has been received in the mean time. 133 | // Simplest (but inefficient) way to make sure we're consistent in this edge-case 134 | // is to reinitialize 135 | this.initializing = false; 136 | this.initializeOutdated = false; 137 | return this.initialize(); 138 | } 139 | 140 | this.powerLevels = powerLevels as any; 141 | members.chunk 142 | .filter( 143 | (e: any) => 144 | e.type === "m.room.member" && 145 | (e.content.membership === "join" || e.content.membership === "invite") 146 | ) 147 | .forEach((e: any) => { 148 | this.members.set(e.state_key, { 149 | displayname: e.content.displayname as string, 150 | user_id: e.state_key as string, 151 | }); 152 | }); 153 | 154 | this.initializing = false; 155 | this.initialized = true; 156 | } 157 | 158 | public dispose() { 159 | this.disposed = true; 160 | super.dispose(); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/reader/MatrixReader.test.ts: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | import { MatrixClient, request } from "matrix-js-sdk"; 3 | import * as qs from "qs"; 4 | import { beforeAll, expect, it } from "vitest"; 5 | import { autocannonSeparateProcess } from "../benchmark/util"; 6 | import { MatrixCRDTEventTranslator } from "../MatrixCRDTEventTranslator"; 7 | import { createMatrixGuestClient } from "../test-utils/matrixGuestClient"; 8 | import { createRandomMatrixClientAndRoom } from "../test-utils/matrixTestUtil"; 9 | import { 10 | ensureMatrixIsRunning, 11 | HOMESERVER_NAME, 12 | matrixTestConfig, 13 | } from "../test-utils/matrixTestUtilServer"; 14 | import { sendMessage } from "../util/matrixUtil"; 15 | import { MatrixReader } from "./MatrixReader"; 16 | 17 | const { Worker, isMainThread } = require("worker_threads"); 18 | 19 | // change http client in matrix, this is faster than request when we have many outgoing requests 20 | request((opts: any, cb: any) => { 21 | opts.url = opts.url || opts.uri; 22 | opts.searchParams = opts.qs; 23 | opts.decompress = opts.gzip; 24 | // opts.responseType = "json"; 25 | opts.throwHttpErrors = false; 26 | if (!opts.json) { 27 | delete opts.json; 28 | } 29 | const responsePromise = got(opts); 30 | const ret = responsePromise.then( 31 | (response) => { 32 | cb(undefined, response, response.body); 33 | }, 34 | (e) => { 35 | cb(e, e.response, e.response.body); 36 | } 37 | ); 38 | (ret as any).abort = responsePromise.cancel; 39 | return ret; 40 | }); 41 | 42 | beforeAll(async () => { 43 | await ensureMatrixIsRunning(); 44 | }); 45 | 46 | function validateMessages(messages: any[], count: number) { 47 | expect(messages.length).toBe(count); 48 | for (let i = 1; i <= count; i++) { 49 | expect(messages[i - 1].content.body).toEqual("message " + i); 50 | } 51 | } 52 | 53 | it("handles initial and live messages", async () => { 54 | let messageId = 0; 55 | const setup = await createRandomMatrixClientAndRoom("public-read"); 56 | 57 | // send more than 1 page (30 messages) initially 58 | for (let i = 0; i < 40; i++) { 59 | await sendMessage(setup.client, setup.roomId, "message " + ++messageId); 60 | } 61 | 62 | const guestClient = await createMatrixGuestClient(matrixTestConfig); 63 | const reader = new MatrixReader( 64 | guestClient, 65 | setup.roomId, 66 | new MatrixCRDTEventTranslator() 67 | ); 68 | try { 69 | const messages = await reader.getInitialDocumentUpdateEvents( 70 | "m.room.message" 71 | ); 72 | 73 | reader.onEvents((msgs) => { 74 | messages.push.apply( 75 | messages, 76 | msgs.events.filter((e) => e.type === "m.room.message") 77 | ); 78 | }); 79 | reader.startPolling(); 80 | 81 | while (messageId < 60) { 82 | await sendMessage(setup.client, setup.roomId, "message " + ++messageId); 83 | } 84 | 85 | await new Promise((resolve) => setTimeout(resolve, 1000)); 86 | validateMessages(messages, messageId); 87 | } finally { 88 | reader.dispose(); 89 | } 90 | }, 100000); 91 | 92 | class TestReader { 93 | private static CREATED = 0; 94 | 95 | public messages: any[] | undefined; 96 | private reader: MatrixReader | undefined; 97 | constructor( 98 | private config: any, 99 | private roomId: string, 100 | private client?: MatrixClient 101 | ) {} 102 | 103 | async initialize() { 104 | const guestClient = 105 | this.client || (await createMatrixGuestClient(matrixTestConfig)); 106 | this.reader = new MatrixReader( 107 | guestClient, 108 | this.roomId, 109 | new MatrixCRDTEventTranslator() 110 | ); 111 | 112 | this.messages = await this.reader.getInitialDocumentUpdateEvents(); 113 | console.log("created", TestReader.CREATED++); 114 | this.reader.onEvents((msgs) => { 115 | // console.log("on message"); 116 | this.messages!.push.apply(this.messages, msgs.events); 117 | }); 118 | this.reader.startPolling(); 119 | } 120 | 121 | dispose() { 122 | this.reader?.dispose(); 123 | } 124 | } 125 | 126 | // Breaks at 500 parallel requests locally 127 | it.skip("handles parallel live messages", async () => { 128 | const PARALLEL = 500; 129 | let messageId = 0; 130 | const setup = await createRandomMatrixClientAndRoom("public-read"); 131 | 132 | const readers = []; 133 | try { 134 | const client = await createMatrixGuestClient(matrixTestConfig); 135 | for (let i = 0; i < PARALLEL; i++) { 136 | // const worker = new Worker(__dirname + "/worker.js", { 137 | // workerData: { 138 | // path: "./MatrixReader.test.ts", 139 | // }, 140 | // }); 141 | readers.push(new TestReader(matrixTestConfig, setup.roomId, client)); 142 | } 143 | 144 | // return; 145 | await Promise.all(readers.map((reader) => reader.initialize())); 146 | 147 | while (messageId < 10) { 148 | // console.log("send message"); 149 | await sendMessage(setup.client, setup.roomId, "message " + ++messageId); 150 | } 151 | 152 | await new Promise((resolve) => setTimeout(resolve, 10000)); 153 | readers.map((r) => validateMessages(r.messages!, messageId)); 154 | } finally { 155 | readers.map((r) => r.dispose()); 156 | } 157 | }); 158 | 159 | // gets slow at around 500 messages, but calls to http://localhost:8888/_matrix/static/ also at around 1k 160 | it.skip("handles parallel live messages autocannon", async () => { 161 | const PARALLEL = 500; 162 | 163 | let messageId = 0; 164 | const setup = await createRandomMatrixClientAndRoom("public-read"); 165 | 166 | const client = await createMatrixGuestClient(matrixTestConfig); 167 | const reader = new MatrixReader( 168 | client, 169 | setup.roomId, 170 | new MatrixCRDTEventTranslator() 171 | ); 172 | try { 173 | await reader.getInitialDocumentUpdateEvents(); 174 | 175 | const params = { 176 | access_token: client.http.opts.accessToken, 177 | from: reader.latestToken, 178 | room_id: setup.roomId, 179 | timeout: 30000, 180 | }; 181 | 182 | // send some new messages beforehand 183 | while (messageId < 10) { 184 | // console.log("send message"); 185 | await sendMessage(setup.client, setup.roomId, "message " + ++messageId); 186 | } 187 | 188 | const uri = 189 | "http://" + 190 | HOMESERVER_NAME + 191 | "/_matrix/client/r0/events?" + 192 | qs.stringify(params); 193 | autocannonSeparateProcess([ 194 | "-c", 195 | PARALLEL + "", 196 | "-a", 197 | PARALLEL + "", 198 | "-t", 199 | "20", 200 | uri, 201 | ]); 202 | 203 | // send some new messages in parallel / after 204 | while (messageId < 20) { 205 | // console.log("send message"); 206 | await sendMessage(setup.client, setup.roomId, "message " + ++messageId); 207 | } 208 | 209 | await new Promise((resolve) => setTimeout(resolve, 10000)); 210 | } finally { 211 | reader.dispose(); 212 | } 213 | }); 214 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/reader/MatrixReader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Direction, 3 | MatrixClient, 4 | MatrixEvent, 5 | Method, 6 | RoomEvent, 7 | } from "matrix-js-sdk"; 8 | import { event, lifecycle } from "vscode-lib"; 9 | import { MatrixCRDTEventTranslator } from "../MatrixCRDTEventTranslator"; 10 | 11 | const PEEK_POLL_TIMEOUT = 30 * 1000; 12 | const PEEK_POLL_ERROR_TIMEOUT = 30 * 1000; 13 | 14 | const DEFAULT_OPTIONS = { 15 | snapshotInterval: 30, // send a snapshot after 30 events 16 | }; 17 | 18 | export type MatrixReaderOptions = Partial; 19 | 20 | /** 21 | * A helper class to read messages from Matrix using a MatrixClient, 22 | * without relying on the sync protocol. 23 | */ 24 | export class MatrixReader extends lifecycle.Disposable { 25 | public latestToken: string | undefined; 26 | private disposed = false; 27 | private polling = false; 28 | private pendingPollRequest: any; 29 | private pollRetryTimeout: ReturnType | undefined; 30 | private messagesSinceSnapshot = 0; 31 | 32 | private readonly _onEvents = this._register( 33 | new event.Emitter<{ events: any[]; shouldSendSnapshot: boolean }>() 34 | ); 35 | public readonly onEvents: event.Event<{ 36 | events: any[]; 37 | shouldSendSnapshot: boolean; 38 | }> = this._onEvents.event; 39 | 40 | private readonly opts: typeof DEFAULT_OPTIONS; 41 | 42 | public constructor( 43 | private matrixClient: MatrixClient, 44 | public readonly roomId: string, 45 | private readonly translator: MatrixCRDTEventTranslator, 46 | opts: MatrixReaderOptions = {} 47 | ) { 48 | super(); 49 | this.opts = { ...DEFAULT_OPTIONS, ...opts }; 50 | // TODO: catch events for when room has been deleted or user has been kicked 51 | this.matrixClient.on(RoomEvent.Timeline, this.matrixRoomListener); 52 | } 53 | 54 | /** 55 | * Only receives messages from rooms the user has joined, and after startClient() has been called 56 | * (i.e.: they're received via the sync API). 57 | * 58 | * At this moment, we only poll for events using the /events endpoint. 59 | * I.e. the Sync API should not be used (and startClient() should not be called). 60 | * 61 | * We do this because we don't want the MatrixClient to keep all events in memory. 62 | * For yjs, this is not necessary, as events are document updates which are accumulated in the yjs 63 | * document, so already stored there. 64 | * 65 | * In a later version, it might be more efficient to call the /sync API manually 66 | * (without relying on the Timeline / sync system in the matrix-js-sdk), 67 | * because it allows us to retrieve events for multiple rooms simultaneously, instead of 68 | * a seperate /events poll per room 69 | */ 70 | private matrixRoomListener = ( 71 | _event: any, 72 | _room: any, 73 | _toStartOfTimeline: boolean 74 | ) => { 75 | console.error("not expected; Room.timeline on MatrixClient"); 76 | // (disable error when testing / developing e2ee support, 77 | // in that case startClient is necessary) 78 | throw new Error( 79 | "unexpected, we don't use /sync calls for MatrixReader, startClient should not be used on the Matrix client" 80 | ); 81 | }; 82 | 83 | /** 84 | * Handle incoming events to determine whether a snapshot message needs to be sent 85 | * 86 | * MatrixReader keeps an internal counter of messages received. 87 | * every opts.snapshotInterval messages, we send a snapshot of the entire document state. 88 | */ 89 | private processIncomingEventsForSnapshot(events: any[]) { 90 | let shouldSendSnapshot = false; 91 | for (let event of events) { 92 | if (this.translator.isUpdateEvent(event)) { 93 | if (event.room_id !== this.roomId) { 94 | throw new Error("event received with invalid roomid"); 95 | } 96 | this.messagesSinceSnapshot++; 97 | if ( 98 | this.messagesSinceSnapshot % this.opts.snapshotInterval === 0 && 99 | event.user_id === this.matrixClient.credentials.userId 100 | ) { 101 | // We don't want multiple users send a snapshot at the same time, 102 | // to prevent this, we have a simple (probably not fool-proof) "snapshot user election" 103 | // system which says that the user who sent a message SNAPSHOT_INTERVAL events since 104 | // the last snapshot is responsible for posting a new snapshot. 105 | 106 | // In case a user fails to do so, 107 | // we use % to make sure we retry this on the next SNAPSHOT_INTERVAL 108 | shouldSendSnapshot = true; 109 | } 110 | } else if (this.translator.isSnapshotEvent(event)) { 111 | this.messagesSinceSnapshot = 0; 112 | shouldSendSnapshot = false; 113 | } 114 | } 115 | return shouldSendSnapshot; 116 | } 117 | 118 | private async decryptRawEventsIfNecessary(rawEvents: any[]) { 119 | const events = await Promise.all( 120 | rawEvents.map(async (event: any) => { 121 | if (event.type === "m.room.encrypted") { 122 | const decrypted = ( 123 | await this.matrixClient.crypto.decryptEvent(new MatrixEvent(event)) 124 | ).clearEvent; 125 | return decrypted; 126 | } else { 127 | return event; 128 | } 129 | }) 130 | ); 131 | return events; 132 | } 133 | 134 | /** 135 | * Peek for new room events using the Matrix /events API (long-polling) 136 | * This function automatically keeps polling until MatrixReader.dispose() is called 137 | */ 138 | private async peekPoll() { 139 | if (!this.latestToken) { 140 | throw new Error("polling but no pagination token"); 141 | } 142 | if (this.disposed) { 143 | return; 144 | } 145 | try { 146 | this.pendingPollRequest = this.matrixClient.http.authedRequest( 147 | undefined as any, 148 | Method.Get, 149 | "/events", 150 | { 151 | room_id: this.roomId, 152 | timeout: PEEK_POLL_TIMEOUT + "", 153 | from: this.latestToken, 154 | }, 155 | undefined, 156 | { localTimeoutMs: PEEK_POLL_TIMEOUT * 2 } 157 | ); 158 | const results = await this.pendingPollRequest; 159 | this.pendingPollRequest = undefined; 160 | if (this.disposed) { 161 | return; 162 | } 163 | 164 | const events = await this.decryptRawEventsIfNecessary(results.chunk); 165 | 166 | const shouldSendSnapshot = this.processIncomingEventsForSnapshot(events); 167 | 168 | if (events.length) { 169 | this._onEvents.fire({ events: events, shouldSendSnapshot }); 170 | } 171 | 172 | this.latestToken = results.end; 173 | this.peekPoll(); 174 | } catch (e) { 175 | console.error("peek error", e); 176 | if (!this.disposed) { 177 | this.pollRetryTimeout = setTimeout( 178 | () => this.peekPoll(), 179 | PEEK_POLL_ERROR_TIMEOUT 180 | ); 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * Before starting polling, call getInitialDocumentUpdateEvents to get the history of events 187 | * when coming online. 188 | * 189 | * This methods paginates back until 190 | * - (a) all events in the room have been received. In that case we return all events. 191 | * - (b) it encounters a snapshot. In this case we return the snapshot event and all update events 192 | * that occur after that latest snapshot 193 | * 194 | * (if typeFilter is set we retrieve all events of that type. TODO: can we deprecate this param?) 195 | */ 196 | public async getInitialDocumentUpdateEvents(typeFilter?: string) { 197 | let ret: any[] = []; 198 | let token = ""; 199 | let hasNextPage = true; 200 | let lastEventInSnapshot: string | undefined; 201 | while (hasNextPage) { 202 | const res = await this.matrixClient.createMessagesRequest( 203 | this.roomId, 204 | token, 205 | 30, 206 | Direction.Backward 207 | // TODO: filter? 208 | ); 209 | 210 | const events = await this.decryptRawEventsIfNecessary(res.chunk); 211 | 212 | for (let event of events) { 213 | if (typeFilter) { 214 | if (event.type === typeFilter) { 215 | ret.push(event); 216 | } 217 | } else if (this.translator.isSnapshotEvent(event)) { 218 | ret.push(event); 219 | lastEventInSnapshot = event.content.last_event_id; 220 | } else if (this.translator.isUpdateEvent(event)) { 221 | if (lastEventInSnapshot && lastEventInSnapshot === event.event_id) { 222 | if (!this.latestToken) { 223 | this.latestToken = res.start; 224 | } 225 | return ret.reverse(); 226 | } 227 | this.messagesSinceSnapshot++; 228 | ret.push(event); 229 | } 230 | } 231 | 232 | token = res.end; 233 | if (!this.latestToken) { 234 | this.latestToken = res.start; 235 | } 236 | hasNextPage = !!(res.start !== res.end && res.end); 237 | } 238 | return ret.reverse(); 239 | } 240 | 241 | /** 242 | * Start polling the room for messages 243 | */ 244 | public startPolling() { 245 | if (this.polling) { 246 | throw new Error("already polling"); 247 | } 248 | this.polling = true; 249 | this.peekPoll(); 250 | } 251 | 252 | public get isStarted() { 253 | return this.polling; 254 | } 255 | 256 | public dispose() { 257 | this.disposed = true; 258 | super.dispose(); 259 | if (this.pollRetryTimeout) { 260 | clearTimeout(this.pollRetryTimeout); 261 | } 262 | if (this.pendingPollRequest) { 263 | this.pendingPollRequest.abort(); 264 | } 265 | this.matrixClient.off(RoomEvent.Timeline, this.matrixRoomListener); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/developit/microbundle/issues/708, otherwise vscode-lib fails 2 | import "regenerator-runtime/runtime.js"; 3 | 4 | const { randomFillSync } = require("crypto"); 5 | (global as any).Olm = require("@matrix-org/olm"); 6 | // const { Crypto } = require("@peculiar/webcrypto"); 7 | // const crypto = new Crypto(); 8 | 9 | Object.defineProperty(globalThis, "crypto", { 10 | value: { 11 | getRandomValues: randomFillSync, 12 | // , subtle: crypto.subtle 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/test-utils/matrixGuestClient.ts: -------------------------------------------------------------------------------- 1 | import { createClient, MemoryStore } from "matrix-js-sdk"; 2 | 3 | export async function createMatrixGuestClient(config: { baseUrl: string }) { 4 | const tmpClient = await createClient(config); 5 | const { user_id, device_id, access_token } = await tmpClient.registerGuest( 6 | {} 7 | ); 8 | let matrixClient = createClient({ 9 | baseUrl: config.baseUrl, 10 | accessToken: access_token, 11 | userId: user_id, 12 | deviceId: device_id, 13 | store: new MemoryStore() as any, 14 | }); 15 | 16 | // hardcoded overwrites 17 | (matrixClient as any).canSupportVoip = false; 18 | (matrixClient as any).clientOpts = { 19 | lazyLoadMembers: true, 20 | }; 21 | 22 | matrixClient.setGuest(true); 23 | await matrixClient.initCrypto(); 24 | // don't use startClient (because it will sync periodically), when we're in guest / readonly mode 25 | // in guest mode we only use the matrixclient to fetch initial room state, but receive updates via WebRTCProvider 26 | 27 | // matrixClient.startClient({ lazyLoadMembers: true }); 28 | 29 | return matrixClient; 30 | } 31 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/test-utils/matrixTestUtil.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import * as https from "https"; 3 | import Matrix, { createClient, MemoryStore } from "matrix-js-sdk"; 4 | import { uuid } from "vscode-lib"; 5 | import { createMatrixRoom } from "../matrixRoomManagement"; 6 | import { matrixTestConfig } from "./matrixTestUtilServer"; 7 | 8 | const request = require("request"); 9 | 10 | export function initMatrixSDK() { 11 | // make sure the matrix sdk initializes request properly 12 | Matrix.request(request); 13 | } 14 | 15 | http.globalAgent.maxSockets = 2000; 16 | https.globalAgent.maxSockets = 2000; 17 | 18 | const TEST_PASSWORD = "testpass"; 19 | 20 | export async function createRandomMatrixClient() { 21 | const testId = uuid.generateUuid(); 22 | const username = "testuser_" + testId; 23 | 24 | const client = await createMatrixUser(username, TEST_PASSWORD); 25 | 26 | return { 27 | username, 28 | client, 29 | }; 30 | } 31 | 32 | export async function createRandomMatrixClientAndRoom( 33 | access: "public-read-write" | "public-read" 34 | ) { 35 | const { client, username } = await createRandomMatrixClient(); 36 | const roomName = "@" + username + "/test"; 37 | const result = await createMatrixRoom(client, roomName, access); 38 | 39 | if (typeof result === "string" || result.status !== "ok") { 40 | throw new Error("couldn't create room"); 41 | } 42 | 43 | return { 44 | client, 45 | roomId: result.roomId, 46 | roomName, 47 | }; 48 | } 49 | 50 | export async function createMatrixUser(username: string, password: string) { 51 | console.log("create", username); 52 | let matrixClient = createClient({ 53 | baseUrl: matrixTestConfig.baseUrl, 54 | // accessToken: access_token, 55 | // userId: user_id, 56 | // deviceId: device_id, 57 | }); 58 | let request = Matrix.getRequest(); 59 | let sessionId = ""; 60 | // first get a session_id. this is returned in a 401 response :/ 61 | try { 62 | const result = await matrixClient.register( 63 | username, 64 | password, 65 | null, 66 | undefined as any 67 | ); 68 | // console.log(result); 69 | } catch (e: any) { 70 | // console.log(e); 71 | sessionId = e.data.session; 72 | } 73 | 74 | if (!sessionId) { 75 | throw new Error("unexpected, no sessionId set"); 76 | } 77 | // now register 78 | 79 | const result = await matrixClient.register(username, password, sessionId, { 80 | type: "m.login.dummy", 81 | }); 82 | // console.log(result); 83 | 84 | // login 85 | const loginResult = await matrixClient.loginWithPassword(username, password); 86 | // console.log(result); 87 | // result.access_token 88 | let matrixClientLoggedIn = createClient({ 89 | baseUrl: matrixTestConfig.baseUrl, 90 | accessToken: loginResult.access_token, 91 | store: new MemoryStore() as any, 92 | userId: loginResult.user_id, 93 | deviceId: loginResult.device_id, 94 | }); 95 | 96 | matrixClientLoggedIn.initCrypto(); 97 | (matrixClientLoggedIn as any).canSupportVoip = false; 98 | (matrixClientLoggedIn as any).clientOpts = { 99 | lazyLoadMembers: true, 100 | }; 101 | return matrixClientLoggedIn; 102 | } 103 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/test-utils/matrixTestUtilServer.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process"; 2 | import fetch from "cross-fetch"; 3 | 4 | export const MATRIX_HOME_URL = new URL("http://localhost:8888/_matrix/static/"); 5 | 6 | export const HOMESERVER_NAME = "localhost:8888"; 7 | export const matrixTestConfig = { 8 | baseUrl: "http://" + HOMESERVER_NAME, 9 | // idBaseUrl: "https://vector.im", 10 | }; 11 | 12 | let matrixStarted = false; 13 | 14 | async function hasMatrixStarted() { 15 | try { 16 | await fetch(MATRIX_HOME_URL.toString()); 17 | return true; 18 | } catch (e) { 19 | return false; 20 | } 21 | } 22 | 23 | async function waitForMatrixStart() { 24 | while (true) { 25 | console.log("Waiting for Matrix to start..."); 26 | if (await hasMatrixStarted()) { 27 | console.log("Matrix has started!"); 28 | return; 29 | } 30 | await new Promise((resolve) => { 31 | setTimeout(resolve, 2000); 32 | }); 33 | } 34 | } 35 | 36 | export async function ensureMatrixIsRunning() { 37 | if (!matrixStarted) { 38 | if (await hasMatrixStarted()) { 39 | matrixStarted = true; 40 | } 41 | } 42 | 43 | if ( 44 | !matrixStarted && 45 | (!process.env.CI || process.env.CI === "vscode-jest-tests") 46 | ) { 47 | matrixStarted = true; 48 | console.log("Starting matrix using docker-compose"); 49 | const ret = cp.execSync("docker compose up -d", { 50 | cwd: "../../test-server/", 51 | }); 52 | console.log(ret.toString("utf-8")); 53 | } 54 | 55 | await waitForMatrixStart(); 56 | } 57 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/util/authUtil.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient } from "matrix-js-sdk"; 2 | import { MatrixMemberReader } from "../memberReader/MatrixMemberReader"; 3 | import { verifySignature } from "./olmlib"; 4 | 5 | /** 6 | * Sign an object (obj) with the users ed25519 key 7 | */ 8 | export async function signObject(client: MatrixClient, obj: any) { 9 | await client.crypto.signObject(obj); 10 | } 11 | 12 | /** 13 | * Verifies whether the signature on obj (obj.signature) is valid. 14 | * This validates: 15 | * - Whether the object has been created by the Matrix user 16 | * (by checking the signature with that user's public key) 17 | * - Whether that user has access to the room (this is delegated to matrixMemberReader) 18 | * 19 | * Throws an error if invalid 20 | */ 21 | export async function verifyObject( 22 | client: MatrixClient, 23 | memberReader: MatrixMemberReader, 24 | obj: any, 25 | eventTypeAccessRequired: string 26 | ) { 27 | if (!obj.signatures || Object.keys(obj.signatures).length !== 1) { 28 | throw new Error("invalid signature"); 29 | } 30 | const userId = Object.keys(obj.signatures)[0]; 31 | if (!memberReader.hasWriteAccess(userId, eventTypeAccessRequired)) { 32 | throw new Error("user doesn't have write access"); 33 | } 34 | 35 | const keyToGet = Object.keys(obj.signatures[userId])[0]; 36 | if (!keyToGet.startsWith("ed25519:")) { 37 | throw new Error("unexpected key"); 38 | } 39 | const deviceToGet = keyToGet.substr("ed25519:".length); 40 | 41 | client.crypto.deviceList.startTrackingDeviceList(userId); 42 | const keys = await client.crypto.deviceList.downloadKeys([userId], false); 43 | const deviceKey = keys[userId][deviceToGet].keys[keyToGet]; 44 | if (!deviceKey) { 45 | throw new Error("key not found"); 46 | } 47 | // This throws an error if it's invalid 48 | await verifySignature( 49 | client.crypto.olmDevice, 50 | obj, 51 | userId, 52 | deviceToGet, 53 | deviceKey 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/util/binary.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/21553528/how-to-test-for-equality-in-arraybuffer-dataview-and-typedarray 2 | 3 | // compare ArrayBuffers 4 | export function arrayBuffersAreEqual(a: ArrayBuffer, b: ArrayBuffer) { 5 | return dataViewsAreEqual(new DataView(a), new DataView(b)); 6 | } 7 | 8 | // compare DataViews 9 | export function dataViewsAreEqual(a: DataView, b: DataView) { 10 | if (a.byteLength !== b.byteLength) return false; 11 | for (let i = 0; i < a.byteLength; i++) { 12 | if (a.getUint8(i) !== b.getUint8(i)) return false; 13 | } 14 | return true; 15 | } 16 | 17 | // compare TypedArrays 18 | export function typedArraysAreEqual(a: Uint8Array, b: Uint8Array) { 19 | if (a.byteLength !== b.byteLength) return false; 20 | return a.every((val, i) => val === b[i]); 21 | } 22 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/util/matrixUtil.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient } from "matrix-js-sdk"; 2 | 3 | export const MESSAGE_EVENT_TYPE = "m.room.message"; 4 | 5 | export async function sendMessage( 6 | client: MatrixClient, 7 | roomId: string, 8 | message: string, 9 | eventType = MESSAGE_EVENT_TYPE 10 | ) { 11 | const content = { 12 | body: message, 13 | msgtype: "m.text", 14 | }; 15 | (client as any).scheduler = undefined; 16 | await client.sendEvent(roomId, eventType, content, ""); 17 | } 18 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/DocWebrtcProvider.ts: -------------------------------------------------------------------------------- 1 | import * as decoding from "lib0/decoding"; 2 | import * as encoding from "lib0/encoding"; 3 | 4 | import * as awarenessProtocol from "y-protocols/awareness"; 5 | import * as syncProtocol from "y-protocols/sync"; 6 | import * as Y from "yjs"; // eslint-disable-line 7 | import { globalRooms } from "./globalResources"; 8 | import { WebrtcProvider } from "./WebrtcProvider"; 9 | import * as logging from "lib0/logging"; 10 | 11 | const log = logging.createModuleLogger("y-webrtc"); 12 | 13 | export const messageSync = 0; 14 | export const messageQueryAwareness = 3; 15 | export const messageAwareness = 1; 16 | 17 | export class DocWebrtcProvider extends WebrtcProvider { 18 | protected onCustomMessage = ( 19 | buf: Uint8Array, 20 | reply: (message: Uint8Array) => void 21 | ): void => { 22 | const decoder = decoding.createDecoder(buf); 23 | const encoder = encoding.createEncoder(); 24 | 25 | const messageType = decoding.readVarUint(decoder); 26 | 27 | switch (messageType) { 28 | case messageSync: { 29 | encoding.writeVarUint(encoder, messageSync); 30 | const syncMessageType = syncProtocol.readSyncMessage( 31 | decoder, 32 | encoder, 33 | this.doc, 34 | this 35 | ); 36 | // if ( 37 | // syncMessageType === syncProtocol.messageYjsSyncStep2 && 38 | // !this.synced 39 | // ) { 40 | // syncedCallback(); 41 | // } 42 | if (syncMessageType === syncProtocol.messageYjsSyncStep1) { 43 | // sendReply = true; 44 | reply(encoding.toUint8Array(encoder)); 45 | } 46 | break; 47 | } 48 | case messageAwareness: 49 | awarenessProtocol.applyAwarenessUpdate( 50 | this.awareness, 51 | decoding.readVarUint8Array(decoder), 52 | this 53 | ); 54 | break; 55 | } 56 | return undefined; 57 | }; 58 | 59 | protected onPeerConnected = (reply: (message: Uint8Array) => void): void => { 60 | const encoder = encoding.createEncoder(); 61 | 62 | // write sync step 1 63 | // TODO: difference: bc used to immediately send syncstep1 + syncstep2 64 | encoding.writeVarUint(encoder, messageSync); 65 | syncProtocol.writeSyncStep1(encoder, this.doc); 66 | reply(encoding.toUint8Array(encoder)); 67 | 68 | // awareness 69 | // TODO: difference: bc used to only send own awareness state (this.doc.clientId) 70 | const encoder2 = encoding.createEncoder(); 71 | const awarenessStates = this.awareness.getStates(); 72 | if (awarenessStates.size > 0) { 73 | encoding.writeVarUint(encoder2, messageAwareness); 74 | encoding.writeVarUint8Array( 75 | encoder2, 76 | awarenessProtocol.encodeAwarenessUpdate( 77 | this.awareness, 78 | Array.from(awarenessStates.keys()) 79 | ) 80 | ); 81 | reply(encoding.toUint8Array(encoder2)); 82 | } 83 | 84 | // old bc code: 85 | // const encoderAwarenessState = encoding.createEncoder(); 86 | // encoding.writeVarUint(encoderAwarenessState, messageAwareness); 87 | // encoding.writeVarUint8Array( 88 | // encoderAwarenessState, 89 | // awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 90 | // this.doc.clientID, 91 | // ]) 92 | // ); 93 | // this.broadcastBcMessage(encoding.toUint8Array(encoderAwarenessState)); 94 | 95 | return undefined; 96 | }; 97 | 98 | /** 99 | * Listens to Yjs updates and sends them to remote peers 100 | */ 101 | private _docUpdateHandler = (update: Uint8Array, origin: any) => { 102 | if (!this.room) { 103 | return; 104 | } 105 | const encoder = encoding.createEncoder(); 106 | encoding.writeVarUint(encoder, messageSync); 107 | syncProtocol.writeUpdate(encoder, update); 108 | this.room.broadcastRoomMessage(encoding.toUint8Array(encoder)); 109 | }; 110 | 111 | /** 112 | * Listens to Awareness updates and sends them to remote peers 113 | */ 114 | private _awarenessUpdateHandler = ( 115 | { added, updated, removed }: any, 116 | origin: any 117 | ) => { 118 | if (!this.room) { 119 | return; 120 | } 121 | 122 | const changedClients = added.concat(updated).concat(removed); 123 | log( 124 | "awareness change ", 125 | { added, updated, removed }, 126 | "local", 127 | this.awareness.clientID 128 | ); 129 | 130 | const encoderAwareness = encoding.createEncoder(); 131 | encoding.writeVarUint(encoderAwareness, messageAwareness); 132 | encoding.writeVarUint8Array( 133 | encoderAwareness, 134 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients) 135 | ); 136 | this.room.broadcastRoomMessage(encoding.toUint8Array(encoderAwareness)); 137 | }; 138 | 139 | constructor( 140 | roomName: string, 141 | private readonly doc: Y.Doc, 142 | opts?: any, 143 | public readonly awareness = new awarenessProtocol.Awareness(doc) 144 | ) { 145 | super(roomName, opts); 146 | 147 | doc.on("destroy", this.destroy.bind(this)); 148 | 149 | this.doc.on("update", this._docUpdateHandler); 150 | this.awareness.on("update", this._awarenessUpdateHandler); 151 | 152 | window.addEventListener("beforeunload", () => { 153 | awarenessProtocol.removeAwarenessStates( 154 | this.awareness, 155 | [doc.clientID], 156 | "window unload" 157 | ); 158 | globalRooms.forEach((room) => { 159 | room.disconnect(); 160 | }); 161 | }); 162 | } 163 | 164 | destroy() { 165 | this.doc.off("update", this._docUpdateHandler); 166 | this.awareness.off("update", this._awarenessUpdateHandler); 167 | this.doc.off("destroy", this.destroy); 168 | super.destroy(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/README.md: -------------------------------------------------------------------------------- 1 | This is a fork of [y-webrtc](https://github.com/yjs/y-webrtc) with some modifications. We needed the modifications to create SignedWebrtcProvider (see ../SignedWebrtcProvider.ts for details). 2 | 3 | See: https://github.com/yjs/y-webrtc/pull/31 for discussion. Modifications made: 4 | 5 | - Migrated to Typescript 6 | - classes moved to separate files 7 | - Most code in y-webrtc was for handling broadcastchannel, signalling and setting up webrtc channels. We've made this explicit by creating a BaseWebrtcProvider.ts provider which handles setting up these connection. Then, the only yjs specific code is implemented in WebrtcProvider.ts. Messages passed over the channels specific to connections / peers are handled in 'Room.ts'. Application / yjs specific messages are then wrapped in a customMessage and handled in WebrtcProvider.ts:onCustomMessage: 8 | - We define a onPeerConnected and onCustomMessage callback that are implemented in WebrtcProvider.ts to send messages in response to initiating a new connection, and in response to custom messages from peers. This means yjs specific logic for broadcast channel vs webrtc peers are now using the same code. The original code had custom behaviour for initiating broadcastchannels (writing messageQueryAwareness, and immediately writing syncstep2). This has now been removed. I don't think this has any impact, but this should be verified 9 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/Room.ts: -------------------------------------------------------------------------------- 1 | import * as bc from "lib0/broadcastchannel"; 2 | import * as decoding from "lib0/decoding"; 3 | import * as encoding from "lib0/encoding"; 4 | import * as logging from "lib0/logging"; 5 | import { createMutex } from "lib0/mutex"; 6 | import * as random from "lib0/random"; 7 | import * as cryptoutils from "./crypto"; 8 | import { announceSignalingInfo, globalSignalingConns } from "./globalResources"; 9 | import { customMessage, messageBcPeerId } from "./messageConstants"; 10 | import { WebrtcConn } from "./WebrtcConn"; 11 | import { WebrtcProvider } from "./WebrtcProvider"; 12 | 13 | const log = logging.createModuleLogger("y-webrtc"); 14 | 15 | export class Room { 16 | /** 17 | * Do not assume that peerId is unique. This is only meant for sending signaling messages. 18 | */ 19 | public readonly peerId = random.uuidv4(); 20 | 21 | private synced = false; 22 | 23 | public readonly webrtcConns = new Map(); 24 | public readonly bcConns = new Set(); 25 | 26 | public readonly mux = createMutex(); 27 | private bcconnected = false; 28 | 29 | private _bcSubscriber = (data: ArrayBuffer) => 30 | cryptoutils.decrypt(new Uint8Array(data), this.key).then((m) => 31 | this.mux(() => { 32 | this.readMessage(m, (reply: encoding.Encoder) => { 33 | this.broadcastBcMessage(encoding.toUint8Array(reply)); 34 | }); 35 | }) 36 | ); 37 | 38 | // public checkIsSynced() { 39 | // let synced = true; 40 | // this.webrtcConns.forEach((peer) => { 41 | // if (!peer.synced) { 42 | // synced = false; 43 | // } 44 | // }); 45 | // if ((!synced && this.synced) || (synced && !this.synced)) { 46 | // this.synced = synced; 47 | // this.provider.emit("synced", [{ synced }]); 48 | // log( 49 | // "synced ", 50 | // logging.BOLD, 51 | // this.name, 52 | // logging.UNBOLD, 53 | // " with all peers" 54 | // ); 55 | // } 56 | // } 57 | 58 | public readMessage = ( 59 | buf: Uint8Array, 60 | reply: (reply: encoding.Encoder) => void 61 | ) => { 62 | const decoder = decoding.createDecoder(buf); 63 | const encoder = encoding.createEncoder(); 64 | 65 | const messageType = decoding.readVarUint(decoder); 66 | 67 | const customReply = (message: Uint8Array) => { 68 | encoding.writeVarUint(encoder, customMessage); 69 | encoding.writeVarUint8Array(encoder, message); 70 | reply(encoder); 71 | }; 72 | 73 | switch (messageType) { 74 | case customMessage: 75 | { 76 | this.onCustomMessage( 77 | decoding.readVarUint8Array(decoder), 78 | customReply 79 | ); 80 | } 81 | break; 82 | // case messageSync: { 83 | // encoding.writeVarUint(encoder, messageSync); 84 | // const syncMessageType = syncProtocol.readSyncMessage( 85 | // decoder, 86 | // encoder, 87 | // this.doc, 88 | // this 89 | // ); 90 | // if ( 91 | // syncMessageType === syncProtocol.messageYjsSyncStep2 && 92 | // !this.synced 93 | // ) { 94 | // syncedCallback(); 95 | // } 96 | // if (syncMessageType === syncProtocol.messageYjsSyncStep1) { 97 | // sendReply = true; 98 | // } 99 | // break; 100 | // } 101 | // case messageQueryAwareness: 102 | // encoding.writeVarUint(encoder, messageAwareness); 103 | // encoding.writeVarUint8Array( 104 | // encoder, 105 | // awarenessProtocol.encodeAwarenessUpdate( 106 | // this.awareness, 107 | // Array.from(this.awareness.getStates().keys()) 108 | // ) 109 | // ); 110 | // sendReply = true; 111 | // break; 112 | // case messageAwareness: 113 | // awarenessProtocol.applyAwarenessUpdate( 114 | // this.awareness, 115 | // decoding.readVarUint8Array(decoder), 116 | // this 117 | // ); 118 | // break; 119 | case messageBcPeerId: { 120 | const add = decoding.readUint8(decoder) === 1; 121 | const peerName = decoding.readVarString(decoder); 122 | if ( 123 | peerName !== this.peerId && 124 | ((this.bcConns.has(peerName) && !add) || 125 | (!this.bcConns.has(peerName) && add)) 126 | ) { 127 | const removed = []; 128 | const added = []; 129 | if (add) { 130 | this.bcConns.add(peerName); 131 | added.push(peerName); 132 | this.onPeerConnected(customReply); 133 | // if (reply) { 134 | // sendReply = true; 135 | // encoding.writeVarUint(encoder, customMessage); 136 | // encoding.writeVarUint8Array(encoder, reply); 137 | // } 138 | } else { 139 | this.bcConns.delete(peerName); 140 | removed.push(peerName); 141 | } 142 | this.provider.emit("peers", [ 143 | { 144 | added, 145 | removed, 146 | webrtcPeers: Array.from(this.webrtcConns.keys()), 147 | bcPeers: Array.from(this.bcConns), 148 | }, 149 | ]); 150 | this.broadcastBcPeerId(); 151 | } 152 | break; 153 | } 154 | default: 155 | console.error("Unable to compute message"); 156 | return; 157 | } 158 | }; 159 | 160 | private broadcastBcPeerId() { 161 | if (this.provider.filterBcConns) { 162 | // broadcast peerId via broadcastchannel 163 | const encoderPeerIdBc = encoding.createEncoder(); 164 | encoding.writeVarUint(encoderPeerIdBc, messageBcPeerId); 165 | encoding.writeUint8(encoderPeerIdBc, 1); 166 | encoding.writeVarString(encoderPeerIdBc, this.peerId); 167 | this.broadcastBcMessage(encoding.toUint8Array(encoderPeerIdBc)); 168 | } 169 | } 170 | 171 | private broadcastWebrtcConn(m: Uint8Array) { 172 | log("broadcast message in ", logging.BOLD, this.name, logging.UNBOLD); 173 | this.webrtcConns.forEach((conn) => { 174 | try { 175 | conn.peer.send(m); 176 | } catch (e) {} 177 | }); 178 | } 179 | 180 | public broadcastRoomMessage(m: Uint8Array) { 181 | const encoder = encoding.createEncoder(); 182 | encoding.writeVarUint(encoder, customMessage); 183 | encoding.writeVarUint8Array(encoder, m); 184 | const reply = encoding.toUint8Array(encoder); 185 | 186 | if (this.bcconnected) { 187 | this.broadcastBcMessage(reply); 188 | } 189 | this.broadcastWebrtcConn(reply); 190 | } 191 | 192 | private broadcastBcMessage(m: Uint8Array) { 193 | return cryptoutils 194 | .encrypt(m, this.key) 195 | .then((data) => this.mux(() => bc.publish(this.name, data))); 196 | } 197 | 198 | // public readonly awareness: awarenessProtocol.Awareness; 199 | 200 | constructor( 201 | public readonly provider: WebrtcProvider, 202 | public readonly onCustomMessage: ( 203 | message: Uint8Array, 204 | reply: (message: Uint8Array) => void 205 | ) => void, 206 | public readonly onPeerConnected: ( 207 | reply: (message: Uint8Array) => void 208 | ) => void, 209 | public readonly name: string, 210 | public readonly key: CryptoKey | undefined 211 | ) { 212 | /** 213 | * @type {awarenessProtocol.Awareness} 214 | */ 215 | // this.awareness = provider.awareness; 216 | // this.doc.on("update", this._docUpdateHandler); 217 | // this.awareness.on("update", this._awarenessUpdateHandler); 218 | } 219 | 220 | connect() { 221 | // signal through all available signaling connections 222 | announceSignalingInfo(this); 223 | const roomName = this.name; 224 | bc.subscribe(roomName, this._bcSubscriber); 225 | this.bcconnected = true; 226 | // broadcast peerId via broadcastchannel 227 | this.broadcastBcPeerId(); 228 | // write sync step 1 229 | // const encoderSync = encoding.createEncoder(); 230 | // encoding.writeVarUint(encoderSync, messageSync); 231 | // syncProtocol.writeSyncStep1(encoderSync, this.doc); 232 | // this.broadcastBcMessage(encoding.toUint8Array(encoderSync)); 233 | // broadcast local state 234 | // const encoderState = encoding.createEncoder(); 235 | // encoding.writeVarUint(encoderState, messageSync); 236 | // syncProtocol.writeSyncStep2(encoderState, this.doc); 237 | // this.broadcastBcMessage(encoding.toUint8Array(encoderState)); 238 | // write queryAwareness 239 | // const encoderAwarenessQuery = encoding.createEncoder(); 240 | // encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness); 241 | // this.broadcastBcMessage(encoding.toUint8Array(encoderAwarenessQuery)); 242 | // broadcast local awareness state 243 | // const encoderAwarenessState = encoding.createEncoder(); 244 | // encoding.writeVarUint(encoderAwarenessState, messageAwareness); 245 | // encoding.writeVarUint8Array( 246 | // encoderAwarenessState, 247 | // awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 248 | // this.doc.clientID, 249 | // ]) 250 | // ); 251 | // this.broadcastBcMessage(encoding.toUint8Array(encoderAwarenessState)); 252 | } 253 | 254 | disconnect() { 255 | // signal through all available signaling connections 256 | globalSignalingConns.forEach((conn) => { 257 | if (conn.connected) { 258 | conn.send({ type: "unsubscribe", topics: [this.name] }); 259 | } 260 | }); 261 | // awarenessProtocol.removeAwarenessStates( 262 | // this.awareness, 263 | // [this.doc.clientID], 264 | // "disconnect" 265 | // ); 266 | // broadcast peerId removal via broadcastchannel 267 | const encoderPeerIdBc = encoding.createEncoder(); 268 | encoding.writeVarUint(encoderPeerIdBc, messageBcPeerId); 269 | encoding.writeUint8(encoderPeerIdBc, 0); // remove peerId from other bc peers 270 | encoding.writeVarString(encoderPeerIdBc, this.peerId); 271 | this.broadcastBcMessage(encoding.toUint8Array(encoderPeerIdBc)); 272 | 273 | bc.unsubscribe(this.name, this._bcSubscriber); 274 | this.bcconnected = false; 275 | // this.doc.off("update", this._docUpdateHandler); 276 | // this.awareness.off("update", this._awarenessUpdateHandler); 277 | this.webrtcConns.forEach((conn) => conn.destroy()); 278 | } 279 | 280 | destroy() { 281 | this.disconnect(); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/SignalingConn.ts: -------------------------------------------------------------------------------- 1 | import * as buffer from "lib0/buffer"; 2 | import * as logging from "lib0/logging"; 3 | import * as map from "lib0/map"; 4 | import * as ws from "lib0/websocket"; 5 | import * as cryptoutils from "./crypto"; 6 | import { globalRooms } from "./globalResources"; 7 | import { Room } from "./Room"; 8 | import { WebrtcConn } from "./WebrtcConn"; 9 | import { WebrtcProvider } from "./WebrtcProvider"; 10 | 11 | const log = logging.createModuleLogger("y-webrtc"); 12 | 13 | export class SignalingConn extends ws.WebsocketClient { 14 | public readonly providers = new Set(); 15 | constructor(url: string) { 16 | super(url); 17 | 18 | this.on("connect", () => { 19 | log(`connected (${url})`); 20 | const topics = Array.from(globalRooms.keys()); 21 | this.send({ type: "subscribe", topics }); 22 | globalRooms.forEach((room) => 23 | this.publishSignalingMessage(room, { 24 | type: "announce", 25 | from: room.peerId, 26 | }) 27 | ); 28 | }); 29 | this.on("message", (m: any) => { 30 | switch (m.type) { 31 | case "publish": { 32 | const roomName = m.topic; 33 | const room = globalRooms.get(roomName); 34 | if (room == null || typeof roomName !== "string") { 35 | return; 36 | } 37 | const execMessage = (data: any) => { 38 | const webrtcConns = room.webrtcConns; 39 | const peerId = room.peerId; 40 | if ( 41 | data == null || 42 | data.from === peerId || 43 | (data.to !== undefined && data.to !== peerId) || 44 | room.bcConns.has(data.from) 45 | ) { 46 | // ignore messages that are not addressed to this conn, or from clients that are connected via broadcastchannel 47 | return; 48 | } 49 | const emitPeerChange = webrtcConns.has(data.from) 50 | ? () => {} 51 | : () => 52 | room.provider.emit("peers", [ 53 | { 54 | removed: [], 55 | added: [data.from], 56 | webrtcPeers: Array.from(room.webrtcConns.keys()), 57 | bcPeers: Array.from(room.bcConns), 58 | }, 59 | ]); 60 | switch (data.type) { 61 | case "announce": 62 | if (webrtcConns.size < room.provider.maxConns) { 63 | map.setIfUndefined( 64 | webrtcConns, 65 | data.from, 66 | () => new WebrtcConn(this, true, data.from, room) 67 | ); 68 | emitPeerChange(); 69 | } 70 | break; 71 | case "signal": 72 | if (data.to === peerId) { 73 | // log("peer.signal", data.signal); 74 | map 75 | .setIfUndefined( 76 | webrtcConns, 77 | data.from, 78 | () => new WebrtcConn(this, false, data.from, room) 79 | ) 80 | .peer.signal(data.signal); 81 | emitPeerChange(); 82 | } 83 | break; 84 | } 85 | }; 86 | if (room.key) { 87 | if (typeof m.data === "string") { 88 | cryptoutils 89 | .decryptJson(buffer.fromBase64(m.data), room.key) 90 | .then(execMessage); 91 | } 92 | } else { 93 | execMessage(m.data); 94 | } 95 | } 96 | } 97 | }); 98 | this.on("disconnect", () => log(`disconnect (${url})`)); 99 | } 100 | 101 | public publishSignalingMessage = (room: Room, data: any) => { 102 | if (room.key) { 103 | cryptoutils.encryptJson(data, room.key).then((data) => { 104 | this.send({ 105 | type: "publish", 106 | topic: room.name, 107 | data: buffer.toBase64(data), 108 | }); 109 | }); 110 | } else { 111 | this.send({ type: "publish", topic: room.name, data }); 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/WebrtcConn.ts: -------------------------------------------------------------------------------- 1 | import * as encoding from "lib0/encoding"; 2 | import * as logging from "lib0/logging"; 3 | import Peer from "simple-peer"; 4 | import { announceSignalingInfo } from "./globalResources"; 5 | import { customMessage } from "./messageConstants"; 6 | import { Room } from "./Room"; 7 | 8 | const log = logging.createModuleLogger("y-webrtc"); 9 | 10 | export class WebrtcConn { 11 | private closed = false; 12 | private connected = false; 13 | public synced = false; 14 | 15 | private sendWebrtcConn(encoder: encoding.Encoder) { 16 | log( 17 | "send message to ", 18 | logging.BOLD, 19 | this.remotePeerId, 20 | logging.UNBOLD, 21 | logging.GREY, 22 | " (", 23 | this.room.name, 24 | ")", 25 | logging.UNCOLOR 26 | ); 27 | try { 28 | this.peer.send(encoding.toUint8Array(encoder)); 29 | } catch (e) {} 30 | } 31 | 32 | public readonly peer: Peer.Instance; 33 | /** 34 | * @param {SignalingConn} signalingConn 35 | * @param {boolean} initiator 36 | * @param {string} remotePeerId 37 | * @param {Room} room 38 | */ 39 | constructor( 40 | signalingConn: any, 41 | initiator: boolean, 42 | private readonly remotePeerId: string, 43 | private readonly room: Room 44 | ) { 45 | log("establishing connection to ", logging.BOLD, remotePeerId); 46 | /** 47 | * @type {any} 48 | */ 49 | this.peer = new Peer({ initiator, ...room.provider.peerOpts }); 50 | this.peer.on("signal", (signal: any) => { 51 | // log( 52 | // "signal log ", 53 | // logging.BOLD, 54 | // "from ", 55 | // room.peerId, 56 | // "to ", 57 | // remotePeerId, 58 | // "initiator ", 59 | // initiator, 60 | // "signal ", 61 | // signal 62 | // ); 63 | signalingConn.publishSignalingMessage(room, { 64 | to: remotePeerId, 65 | from: room.peerId, 66 | type: "signal", 67 | signal, 68 | }); 69 | }); 70 | this.peer.on("connect", () => { 71 | log("connected to ", logging.BOLD, remotePeerId); 72 | this.connected = true; 73 | // send sync step 1 74 | // const provider = room.provider; 75 | // const doc = provider.doc; 76 | // const awareness = room.awareness; 77 | // const encoder = encoding.createEncoder(); 78 | // encoding.writeVarUint(encoder, messageSync); 79 | // syncProtocol.writeSyncStep1(encoder, doc); 80 | // this.sendWebrtcConn(encoder); 81 | room.onPeerConnected((reply) => { 82 | const encoder = encoding.createEncoder(); 83 | encoding.writeVarUint(encoder, customMessage); 84 | encoding.writeVarUint8Array(encoder, reply); 85 | this.sendWebrtcConn(encoder); 86 | }); 87 | 88 | // const awarenessStates = awareness.getStates(); 89 | // if (awarenessStates.size > 0) { 90 | // const encoder = encoding.createEncoder(); 91 | // encoding.writeVarUint(encoder, messageAwareness); 92 | // encoding.writeVarUint8Array( 93 | // encoder, 94 | // awarenessProtocol.encodeAwarenessUpdate( 95 | // awareness, 96 | // Array.from(awarenessStates.keys()) 97 | // ) 98 | // ); 99 | // this.sendWebrtcConn(encoder); 100 | // } 101 | }); 102 | this.peer.on("close", () => { 103 | this.connected = false; 104 | this.closed = true; 105 | if (room.webrtcConns.has(this.remotePeerId)) { 106 | room.webrtcConns.delete(this.remotePeerId); 107 | room.provider.emit("peers", [ 108 | { 109 | removed: [this.remotePeerId], 110 | added: [], 111 | webrtcPeers: Array.from(room.webrtcConns.keys()), 112 | bcPeers: Array.from(room.bcConns), 113 | }, 114 | ]); 115 | } 116 | // room.checkIsSynced(); 117 | this.peer.destroy(); 118 | log("closed connection to ", logging.BOLD, remotePeerId); 119 | announceSignalingInfo(room); 120 | }); 121 | this.peer.on("error", (err: any) => { 122 | log("Error in connection to ", logging.BOLD, remotePeerId, ": ", err); 123 | announceSignalingInfo(room); 124 | }); 125 | this.peer.on("data", (data: Uint8Array) => { 126 | log( 127 | "received message from ", 128 | logging.BOLD, 129 | this.remotePeerId, 130 | logging.GREY, 131 | " (", 132 | room.name, 133 | ")", 134 | logging.UNBOLD, 135 | logging.UNCOLOR 136 | ); 137 | 138 | this.room.readMessage(data, (reply: encoding.Encoder) => { 139 | this.sendWebrtcConn(reply); 140 | }); 141 | }); 142 | } 143 | 144 | destroy() { 145 | this.peer.destroy(); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/WebrtcProvider.ts: -------------------------------------------------------------------------------- 1 | import * as error from "lib0/error"; 2 | import * as logging from "lib0/logging"; 3 | import * as map from "lib0/map"; 4 | import * as math from "lib0/math"; 5 | import { Observable } from "lib0/observable"; 6 | import * as random from "lib0/random"; 7 | import * as cryptoutils from "./crypto"; 8 | import { globalRooms, globalSignalingConns } from "./globalResources"; 9 | import { Room } from "./Room"; 10 | import { SignalingConn } from "./SignalingConn"; 11 | 12 | const log = logging.createModuleLogger("y-webrtc"); 13 | 14 | const openRoom = ( 15 | provider: WebrtcProvider, 16 | onCustomMessage: ( 17 | message: Uint8Array, 18 | reply: (message: Uint8Array) => void 19 | ) => void, 20 | onPeerConnected: (reply: (message: Uint8Array) => void) => void, 21 | name: string, 22 | key: CryptoKey | undefined 23 | ) => { 24 | // there must only be one room 25 | if (globalRooms.has(name)) { 26 | throw error.create(`A Yjs Doc connected to room "${name}" already exists!`); 27 | } 28 | const room = new Room(provider, onCustomMessage, onPeerConnected, name, key); 29 | globalRooms.set(name, room); 30 | return room; 31 | }; 32 | 33 | export abstract class WebrtcProvider extends Observable { 34 | // public readonly awareness: awarenessProtocol.Awareness; 35 | private shouldConnect = false; 36 | public readonly filterBcConns: boolean = true; 37 | private readonly signalingUrls: string[]; 38 | private readonly signalingConns: SignalingConn[]; 39 | public readonly peerOpts: any; 40 | public readonly maxConns: number; 41 | private readonly key: Promise; 42 | protected room: Room | undefined; 43 | 44 | protected abstract onCustomMessage: ( 45 | message: Uint8Array, 46 | reply: (message: Uint8Array) => void 47 | ) => void; 48 | protected abstract onPeerConnected: ( 49 | reply: (message: Uint8Array) => void 50 | ) => void; 51 | 52 | constructor( 53 | private readonly roomName: string, 54 | 55 | // public readonly doc: Y.Doc, 56 | { 57 | signaling = [ 58 | "wss://signaling.yjs.dev", 59 | "wss://y-webrtc-signaling-eu.herokuapp.com", 60 | "wss://y-webrtc-signaling-us.herokuapp.com", 61 | ], 62 | password = undefined as undefined | string, 63 | // awareness = new awarenessProtocol.Awareness(doc), 64 | maxConns = 20 + math.floor(random.rand() * 15), // the random factor reduces the chance that n clients form a cluster 65 | filterBcConns = true, 66 | peerOpts = {}, // simple-peer options. See https://github.com/feross/simple-peer#peer--new-peeropts 67 | } = {} 68 | ) { 69 | super(); 70 | this.filterBcConns = filterBcConns; 71 | // this.awareness = awareness; 72 | this.shouldConnect = false; 73 | this.signalingUrls = signaling; 74 | this.signalingConns = []; 75 | this.maxConns = maxConns; 76 | this.peerOpts = { iceServers: [] }; 77 | this.key = password 78 | ? cryptoutils.deriveKey(password, roomName) 79 | : Promise.resolve(undefined); 80 | 81 | this.key.then((key) => { 82 | this.room = openRoom( 83 | this, 84 | this.onCustomMessage, 85 | this.onPeerConnected, 86 | roomName, 87 | key 88 | ); 89 | if (this.shouldConnect) { 90 | this.room.connect(); 91 | } else { 92 | this.room.disconnect(); 93 | } 94 | }); 95 | this.connect(); 96 | // this.destroy = this.destroy.bind(this); 97 | // doc.on("destroy", this.destroy); 98 | 99 | // window.addEventListener("beforeunload", () => { 100 | // awarenessProtocol.removeAwarenessStates( 101 | // this.awareness, 102 | // [doc.clientID], 103 | // "window unload" 104 | // ); 105 | // globalRooms.forEach((room) => { 106 | // room.disconnect(); 107 | // }); 108 | // }); 109 | } 110 | 111 | /** 112 | * @type {boolean} 113 | */ 114 | get connected() { 115 | return this.room !== null && this.shouldConnect; 116 | } 117 | 118 | connect() { 119 | this.shouldConnect = true; 120 | this.signalingUrls.forEach((url) => { 121 | const signalingConn = map.setIfUndefined( 122 | globalSignalingConns, 123 | url, 124 | () => new SignalingConn(url) 125 | ); 126 | this.signalingConns.push(signalingConn); 127 | signalingConn.providers.add(this); 128 | }); 129 | if (this.room) { 130 | this.room.connect(); 131 | } 132 | } 133 | 134 | disconnect() { 135 | this.shouldConnect = false; 136 | this.signalingConns.forEach((conn) => { 137 | conn.providers.delete(this); 138 | if (conn.providers.size === 0) { 139 | conn.destroy(); 140 | globalSignalingConns.delete(conn.url); 141 | } 142 | }); 143 | if (this.room) { 144 | this.room.disconnect(); 145 | } 146 | } 147 | 148 | destroy() { 149 | // need to wait for key before deleting room 150 | this.key.then(() => { 151 | this.room?.destroy(); 152 | globalRooms.delete(this.roomName); 153 | }); 154 | super.destroy(); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/crypto.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import * as encoding from "lib0/encoding"; 4 | import * as decoding from "lib0/decoding"; 5 | import * as promise from "lib0/promise"; 6 | import * as error from "lib0/error"; 7 | import * as string from "lib0/string"; 8 | 9 | /** 10 | * @param {string} secret 11 | * @param {string} roomName 12 | * @return {PromiseLike} 13 | */ 14 | export const deriveKey = (secret: string, roomName: string) => { 15 | const secretBuffer = string.encodeUtf8(secret).buffer; 16 | const salt = string.encodeUtf8(roomName).buffer; 17 | return crypto.subtle 18 | .importKey("raw", secretBuffer, "PBKDF2", false, ["deriveKey"]) 19 | .then((keyMaterial) => 20 | crypto.subtle.deriveKey( 21 | { 22 | name: "PBKDF2", 23 | salt, 24 | iterations: 100000, 25 | hash: "SHA-256", 26 | }, 27 | keyMaterial, 28 | { 29 | name: "AES-GCM", 30 | length: 256, 31 | }, 32 | true, 33 | ["encrypt", "decrypt"] 34 | ) 35 | ); 36 | }; 37 | 38 | /** 39 | * @param {Uint8Array} data data to be encrypted 40 | * @param {CryptoKey?} key 41 | * @return {PromiseLike} encrypted, base64 encoded message 42 | */ 43 | export const encrypt = async (data: Uint8Array, key?: CryptoKey) => { 44 | if (!key) { 45 | return data; 46 | } 47 | const iv = crypto.getRandomValues(new Uint8Array(12)); 48 | return crypto.subtle 49 | .encrypt( 50 | { 51 | name: "AES-GCM", 52 | iv, 53 | }, 54 | key, 55 | data 56 | ) 57 | .then((cipher) => { 58 | const encryptedDataEncoder = encoding.createEncoder(); 59 | encoding.writeVarString(encryptedDataEncoder, "AES-GCM"); 60 | encoding.writeVarUint8Array(encryptedDataEncoder, iv); 61 | encoding.writeVarUint8Array(encryptedDataEncoder, new Uint8Array(cipher)); 62 | return encoding.toUint8Array(encryptedDataEncoder); 63 | }); 64 | }; 65 | 66 | /** 67 | * @param {Object} data data to be encrypted 68 | * @param {CryptoKey?} key 69 | * @return {PromiseLike} encrypted data, if key is provided 70 | */ 71 | export const encryptJson = (data: any, key?: CryptoKey) => { 72 | const dataEncoder = encoding.createEncoder(); 73 | encoding.writeAny(dataEncoder, data); 74 | return encrypt(encoding.toUint8Array(dataEncoder), key); 75 | }; 76 | 77 | /** 78 | * @param {Uint8Array} data 79 | * @param {CryptoKey?} key 80 | * @return {PromiseLike} decrypted buffer 81 | */ 82 | export const decrypt = async (data: Uint8Array, key?: CryptoKey) => { 83 | if (!key) { 84 | return data; 85 | } 86 | const dataDecoder = decoding.createDecoder(data); 87 | const algorithm = decoding.readVarString(dataDecoder); 88 | if (algorithm !== "AES-GCM") { 89 | promise.reject(error.create("Unknown encryption algorithm")); 90 | } 91 | const iv = decoding.readVarUint8Array(dataDecoder); 92 | const cipher = decoding.readVarUint8Array(dataDecoder); 93 | return crypto.subtle 94 | .decrypt( 95 | { 96 | name: "AES-GCM", 97 | iv, 98 | }, 99 | key, 100 | cipher 101 | ) 102 | .then((data) => new Uint8Array(data)); 103 | }; 104 | 105 | /** 106 | * @param {Uint8Array} data 107 | * @param {CryptoKey?} key 108 | * @return {PromiseLike} decrypted object 109 | */ 110 | export const decryptJson = (data: Uint8Array, key?: CryptoKey) => 111 | decrypt(data, key).then((decryptedValue) => 112 | decoding.readAny(decoding.createDecoder(new Uint8Array(decryptedValue))) 113 | ); 114 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/globalResources.ts: -------------------------------------------------------------------------------- 1 | import { Room } from "./Room"; 2 | import { SignalingConn } from "./SignalingConn"; 3 | 4 | export const globalSignalingConns = new Map(); 5 | export const globalRooms = new Map(); 6 | 7 | export function announceSignalingInfo(room: Room) { 8 | globalSignalingConns.forEach((conn) => { 9 | // only subcribe if connection is established, otherwise the conn automatically subscribes to all rooms 10 | if (conn.connected) { 11 | conn.send({ type: "subscribe", topics: [room.name] }); 12 | if (room.webrtcConns.size < room.provider.maxConns) { 13 | conn.publishSignalingMessage(room, { 14 | type: "announce", 15 | from: room.peerId, 16 | }); 17 | } 18 | } 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/webrtc/messageConstants.ts: -------------------------------------------------------------------------------- 1 | export const messageBcPeerId = 4; 2 | export const customMessage = 5; 3 | -------------------------------------------------------------------------------- /packages/matrix-crdt/src/writer/ThrottledMatrixWriter.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { MatrixClient } from "matrix-js-sdk"; 3 | import { event, lifecycle } from "vscode-lib"; 4 | import * as Y from "yjs"; 5 | import { MatrixCRDTEventTranslator } from "../MatrixCRDTEventTranslator"; 6 | 7 | const DEFAULT_OPTIONS = { 8 | // throttle flushing write events to matrix by 500ms 9 | flushInterval: process.env.NODE_ENV === "test" ? 100 : 100 * 5, 10 | // if writing to the room fails, wait 30 seconds before retrying 11 | retryIfForbiddenInterval: 1000 * 30, 12 | }; 13 | 14 | export type ThrottledMatrixWriterOptions = Partial; 15 | 16 | /** 17 | * A class that writes updates in the form of Uint8Array to Matrix. 18 | */ 19 | export class ThrottledMatrixWriter extends lifecycle.Disposable { 20 | private pendingUpdates: Uint8Array[] = []; 21 | private isSendingUpdates = false; 22 | private _canWrite = true; 23 | private retryTimeoutHandler: any; 24 | private roomId: string | undefined; 25 | 26 | private readonly _onCanWriteChanged: event.Emitter = this._register( 27 | new event.Emitter() 28 | ); 29 | 30 | public readonly onCanWriteChanged: event.Event = 31 | this._onCanWriteChanged.event; 32 | 33 | private readonly _onSentAllEvents: event.Emitter = this._register( 34 | new event.Emitter() 35 | ); 36 | 37 | private readonly onSentAllEvents: event.Event = 38 | this._onSentAllEvents.event; 39 | 40 | private readonly throttledFlushUpdatesToMatrix: _.DebouncedFunc< 41 | () => Promise 42 | >; 43 | 44 | private readonly opts: typeof DEFAULT_OPTIONS; 45 | 46 | constructor( 47 | private readonly matrixClient: MatrixClient, 48 | private readonly translator: MatrixCRDTEventTranslator, 49 | opts: ThrottledMatrixWriterOptions = {} 50 | ) { 51 | super(); 52 | this.opts = { ...DEFAULT_OPTIONS, ...opts }; 53 | this.throttledFlushUpdatesToMatrix = _.throttle( 54 | this.flushUpdatesToMatrix, 55 | this.canWrite 56 | ? this.opts.flushInterval 57 | : this.opts.retryIfForbiddenInterval 58 | ); 59 | } 60 | 61 | private setCanWrite(value: boolean) { 62 | if (this._canWrite !== value) { 63 | this._canWrite = value; 64 | this._onCanWriteChanged.fire(); 65 | } 66 | } 67 | 68 | private flushUpdatesToMatrix = async () => { 69 | if (this.isSendingUpdates || !this.pendingUpdates.length) { 70 | return; 71 | } 72 | 73 | if (!this.roomId) { 74 | // we're still initializing. We'll flush updates again once we're initialized 75 | return; 76 | } 77 | this.isSendingUpdates = true; 78 | const merged = Y.mergeUpdates(this.pendingUpdates); 79 | this.pendingUpdates = []; 80 | 81 | let retryImmediately = false; 82 | try { 83 | console.log("Sending updates"); 84 | await this.translator.sendUpdate(this.matrixClient, this.roomId, merged); 85 | this.setCanWrite(true); 86 | console.log("sent updates"); 87 | } catch (e: any) { 88 | if (e.errcode === "M_FORBIDDEN") { 89 | console.warn("not allowed to edit document", e); 90 | this.setCanWrite(false); 91 | 92 | try { 93 | // make sure we're in the room, so we can send updates 94 | // guests can't / won't join, so MatrixProvider won't send updates for this room 95 | await this.matrixClient.joinRoom(this.roomId); 96 | console.log("joined room", this.roomId); 97 | retryImmediately = true; 98 | } catch (e) { 99 | console.warn("failed to join room", e); 100 | } 101 | } else { 102 | console.error("error sending updates", e); 103 | } 104 | this.pendingUpdates.unshift(merged); 105 | } finally { 106 | this.isSendingUpdates = false; 107 | } 108 | 109 | if (this.pendingUpdates.length) { 110 | // if new events have been added in the meantime (or we need to retry) 111 | this.retryTimeoutHandler = setTimeout( 112 | () => { 113 | this.throttledFlushUpdatesToMatrix(); 114 | }, 115 | retryImmediately 116 | ? 0 117 | : this.canWrite 118 | ? this.opts.flushInterval 119 | : this.opts.retryIfForbiddenInterval 120 | ); 121 | } else { 122 | console.log("_onSentAllEvents"); 123 | this._onSentAllEvents.fire(); 124 | } 125 | }; 126 | 127 | public async initialize(roomId: string) { 128 | this.roomId = roomId; 129 | this.throttledFlushUpdatesToMatrix(); 130 | } 131 | 132 | public get canWrite() { 133 | return this._canWrite; 134 | } 135 | 136 | public writeUpdate(update: Uint8Array) { 137 | this.pendingUpdates.push(update); 138 | this.throttledFlushUpdatesToMatrix(); 139 | } 140 | 141 | // Helper method that's mainly used in unit tests 142 | public async waitForFlush() { 143 | if (!this.pendingUpdates.length && !this.isSendingUpdates) { 144 | return; 145 | } 146 | await event.Event.toPromise(this.onSentAllEvents); 147 | } 148 | 149 | public dispose() { 150 | super.dispose(); 151 | clearTimeout(this.retryTimeoutHandler); 152 | this.throttledFlushUpdatesToMatrix.cancel(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /packages/matrix-crdt/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": false, 17 | "declaration": true, 18 | "declarationDir": "types", 19 | "sourceMap": true, 20 | "downlevelIteration": true, 21 | "outDir": "dist" 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/matrix-crdt/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | name: "matrix-crdt", 8 | entry: resolve(__dirname, "src/index.ts"), 9 | }, 10 | rollupOptions: { 11 | // make sure to externalize deps that shouldn't be bundled 12 | // into your library 13 | external: [ 14 | "yjs", 15 | "vscode-lib", 16 | "lib0", 17 | "matrix-js-sdk", 18 | "y-protocols", 19 | "lodash", 20 | "simple-peer", 21 | "another-json", 22 | ], 23 | }, 24 | }, 25 | test: { 26 | setupFiles: "src/setupTests.ts", 27 | coverage: { 28 | reporter: ["text", "json", "html", "lcov"], 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | $schema: "http://json.schemastore.org/prettierrc", 3 | tabWidth: 2, 4 | printWidth: 80, 5 | jsxBracketSameLine: true, 6 | }; 7 | -------------------------------------------------------------------------------- /test-server/.gitignore: -------------------------------------------------------------------------------- 1 | schemas 2 | !data -------------------------------------------------------------------------------- /test-server/README.md: -------------------------------------------------------------------------------- 1 | # Synapse testing server 2 | 3 | This directory contains the docker configuration to run a local Synapse Matrix server. It's used in unit tests. 4 | 5 | To run locally, you might need to run `chmod -R 777 data` 6 | -------------------------------------------------------------------------------- /test-server/data/homeserver.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YousefED/Matrix-CRDT/b2fbb8ce14ff8d53e28b8b854efb5fd40391348d/test-server/data/homeserver.log -------------------------------------------------------------------------------- /test-server/data/localhost-8888.log.config: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | formatters: 4 | precise: 5 | 6 | format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' 7 | 8 | 9 | handlers: 10 | file: 11 | class: logging.handlers.TimedRotatingFileHandler 12 | formatter: precise 13 | filename: /data/homeserver.log 14 | when: "midnight" 15 | backupCount: 6 # Does not include the current log file. 16 | encoding: utf8 17 | 18 | # Default to buffering writes to log file for efficiency. This means that 19 | # there will be a delay for INFO/DEBUG logs to get written, but WARNING/ERROR 20 | # logs will still be flushed immediately. 21 | buffer: 22 | class: logging.handlers.MemoryHandler 23 | target: file 24 | # The capacity is the number of log lines that are buffered before 25 | # being written to disk. Increasing this will lead to better 26 | # performance, at the expensive of it taking longer for log lines to 27 | # be written to disk. 28 | capacity: 10 29 | flushLevel: 30 # Flush for WARNING logs as well 30 | 31 | console: 32 | class: logging.StreamHandler 33 | formatter: precise 34 | 35 | loggers: 36 | synapse.storage.SQL: 37 | # beware: increasing this to DEBUG will make synapse log sensitive 38 | # information such as access tokens. 39 | level: INFO 40 | 41 | root: 42 | level: INFO 43 | 44 | 45 | handlers: [console] 46 | 47 | 48 | disable_existing_loggers: false -------------------------------------------------------------------------------- /test-server/data/localhost-8888.signing.key: -------------------------------------------------------------------------------- 1 | ed25519 a_JNBN M1sMNzpSvSkJVr8be+ln0t9Nd4VbMFAl0z/eRcwTEiU 2 | -------------------------------------------------------------------------------- /test-server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # THIS DOCKER-COMPOSE file is only to be used for benchmarking / test suites 2 | 3 | version: "3" 4 | 5 | services: 6 | synapse: 7 | container_name: synapse 8 | # build: 9 | # context: ./ 10 | # dockerfile: docker/Dockerfile 11 | image: docker.io/matrixdotorg/synapse:latest 12 | # Since synapse does not retry to connect to the database, restart upon 13 | # failure 14 | restart: unless-stopped 15 | # See the readme for a full documentation of the environment settings 16 | environment: 17 | - SYNAPSE_CONFIG_PATH=/data/homeserver.yaml 18 | volumes: 19 | # You may either store all the files in a local folder 20 | - ./data:/data 21 | # .. or you may split this between different storage points 22 | # - ./files:/data 23 | # - /path/to/ssd:/data/uploads 24 | # - /path/to/large_hdd:/data/media 25 | depends_on: 26 | - db 27 | # In order to expose Synapse, remove one of the following, you might for 28 | # instance expose the TLS port directly: 29 | ports: 30 | - 8888:8888/tcp 31 | # ... or use a reverse proxy, here is an example for traefik: 32 | # labels: 33 | # # The following lines are valid for Traefik version 1.x: 34 | # - traefik.enable=true 35 | # - traefik.frontend.rule=Host:my.matrix.Host 36 | # - traefik.port=8008 37 | # # Alternatively, for Traefik version 2.0: 38 | # - traefik.enable=true 39 | # - traefik.http.routers.http-synapse.entryPoints=http 40 | # - traefik.http.routers.http-synapse.rule=Host(`my.matrix.host`) 41 | # - traefik.http.middlewares.https_redirect.redirectscheme.scheme=https 42 | # - traefik.http.middlewares.https_redirect.redirectscheme.permanent=true 43 | # - traefik.http.routers.http-synapse.middlewares=https_redirect 44 | # - traefik.http.routers.https-synapse.entryPoints=https 45 | # - traefik.http.routers.https-synapse.rule=Host(`my.matrix.host`) 46 | # - traefik.http.routers.https-synapse.service=synapse 47 | # - traefik.http.routers.https-synapse.tls=true 48 | # - traefik.http.services.synapse.loadbalancer.server.port=8008 49 | # - traefik.http.routers.https-synapse.tls.certResolver=le-ssl 50 | db: 51 | ports: 52 | - 5432:5432 53 | image: docker.io/postgres:12-alpine 54 | # Change that password, of course! 55 | environment: 56 | - POSTGRES_USER=benchmarkuser 57 | - POSTGRES_PASSWORD=benchmarkpw 58 | - POSTGRES_DB=benchmarkdb 59 | # ensure the database gets created correctly 60 | # https://github.com/matrix-org/synapse/blob/master/docs/postgres.md#set-up-database 61 | - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C 62 | volumes: 63 | # You may store the database tables in a local folder.. 64 | - ./schemas:/var/lib/postgresql/data 65 | # .. or store them on some high performance storage for better results 66 | # - /path/to/ssd/storage:/var/lib/postgresql/data 67 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "paths": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": false, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strictNullChecks": true, 10 | "strict": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": false, 17 | "noEmit": true, 18 | "jsx": "react", 19 | "experimentalDecorators": true, 20 | "outDir": "types", 21 | "declaration": true, 22 | "baseUrl": ".", 23 | "paths": {} 24 | }, 25 | "exclude": ["**/dist"] 26 | } 27 | --------------------------------------------------------------------------------