├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── errors ├── dont-freeze.md └── infinite-loop.md ├── example ├── .gitignore ├── README.md ├── next-env.d.ts ├── package.json ├── pages │ ├── _app.js │ ├── api │ │ └── hello.js │ ├── index.tsx │ ├── infinite-loop.tsx │ ├── lists.tsx │ ├── local-subs.tsx │ └── presence.tsx ├── public │ ├── favicon.ico │ └── vercel.svg ├── styles.css ├── tsconfig.json └── yarn.lock ├── misc └── logo.svg ├── package.json ├── src ├── ListClient.test.ts ├── ListClient.ts ├── MapClient.test.ts ├── MapClient.ts ├── PresenceClient.ts ├── RoomClient.test.ts ├── RoomClient.ts ├── RoomServiceClient.ts ├── constants.ts ├── errs.ts ├── index.ts ├── localbus.test.ts ├── localbus.ts ├── remote.test.ts ├── remote.ts ├── throttle.ts ├── types.ts ├── util.ts ├── ws.test.ts ├── ws.ts └── wsMessages.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | misc 4 | dist 5 | coverage 6 | .vscode -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": false 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Flaque 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # @roomservice/browser 6 | 7 | [Room Service](https://www.roomservice.dev/) helps you add real-time collaboration to your app. It's a real-time service with a built-in [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) that automatically merges multiple people's state together without horrifying nightmare bugs. To learn more, see [roomservice.dev](https://www.roomservice.dev). 8 | 9 | This is the official, ~javascript~ typescript SDK. 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm install --save @roomservice/browser 15 | ``` 16 | -------------------------------------------------------------------------------- /errors/dont-freeze.md: -------------------------------------------------------------------------------- 1 | # Don't Freeze 2 | 3 | Don't call `Object.freeze()` on the RoomServiceClient or the RoomClient. 4 | 5 | ## Why this error is happening 6 | 7 | Some libraries, such as Recoil.js, will ["freeze"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) a javascript object, making any the object immutable, and causing any updates to the object to throw an error. 8 | 9 | Some of Room Service's object's keep an internal cache necessary to prevent bugs and keep your code fast. In doing so, the object will occasionally need to update itself (`this.foobar = "..."`), and will break if frozen. 10 | 11 | ## How to fix 12 | 13 | ### If you're using Recoil.js 14 | 15 | Don't store `RoomClient` or `RoomServiceClient` in `useRecoilState`. 16 | 17 | ### If you're using `Object.freeze()` 18 | 19 | Don't freeze `RoomClient` or `RoomServiceClient`. 20 | -------------------------------------------------------------------------------- /errors/infinite-loop.md: -------------------------------------------------------------------------------- 1 | # Infinite Loop 2 | 3 | Infinite loop detected. 4 | 5 | ## Why this error is happening 6 | 7 | Somewhere in your code, you're trying to change an object (like a Map or a List) within the `subscribe` function that's listening to that object. Since this would cause an infinite loop, Room Service throws an error. 8 | 9 | For example, this code throws an "Infinite loop detected" error: 10 | 11 | ```tsx 12 | // This is causes an infinite loop 13 | room.subscribe(map, (json) => { 14 | map.set('name', 'joe'); 15 | }); 16 | ``` 17 | 18 | ## How to fix 19 | 20 | Ensure you're not making updates inside of subscription functions. For example: 21 | 22 | ```tsx 23 | room.subscribe(map, (json) => { 24 | // ... 25 | }); 26 | 27 | // This does not cause an infinite loop 28 | nextMap.set('name', 'joe'); 29 | ``` 30 | -------------------------------------------------------------------------------- /example/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "9.5.4", 12 | "react": "16.13.1", 13 | "react-dom": "16.13.1" 14 | }, 15 | "devDependencies": { 16 | "@types/react": "^16.9.47" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles.css'; 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return ; 5 | } 6 | 7 | export default MyApp; 8 | -------------------------------------------------------------------------------- /example/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | function getRandomInt(min, max) { 4 | min = Math.ceil(min); 5 | max = Math.floor(max); 6 | return Math.floor(Math.random() * (max - min + 1)) + min; 7 | } 8 | 9 | export default async (req, res) => { 10 | const body = req.body; 11 | const API_KEY = 'nEK9OXZsk5G0gdEGieqwy'; 12 | const user = 'some-user-' + getRandomInt(1, 200); 13 | 14 | const r = await fetch('https://super.roomservice.dev/provision', { 15 | method: 'post', 16 | headers: { 17 | Authorization: `Bearer: ${API_KEY}`, 18 | 'Content-Type': 'application/json', 19 | }, 20 | body: JSON.stringify({ 21 | user: user, 22 | resources: body.resources, 23 | }), 24 | }); 25 | 26 | const json = await r.json(); 27 | 28 | res.json(json); 29 | }; 30 | -------------------------------------------------------------------------------- /example/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { RoomService, PresenceClient } from '@roomservice/browser'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | function Cursor(props: { fill?: string; x: number; y: number }) { 5 | return ( 6 |
15 | 23 | 24 | 28 | 32 | 33 | 34 | 43 | 44 | 49 | 50 | 51 | 55 | 60 | 66 | 67 | 68 | 69 |
70 | ); 71 | } 72 | 73 | interface Position { 74 | x: number; 75 | y: number; 76 | } 77 | 78 | export default function Home() { 79 | const [presence, setPresence] = useState(); 80 | const [positions, setPositions] = useState<{ [key: string]: Position }>({}); 81 | const [me, setMe] = useState(''); 82 | 83 | useEffect(() => { 84 | async function load() { 85 | const rs = new RoomService({ 86 | auth: '/api/hello', 87 | }); 88 | 89 | const room = await rs.room('wefae'); 90 | const p = room.presence(); 91 | setPresence(p); 92 | setMe(p.me); 93 | 94 | const v = await p.getAll('position'); 95 | setPositions(v); 96 | return room.subscribe(p, 'position', (msg) => { 97 | setPositions(msg); 98 | }); 99 | } 100 | 101 | load().catch(console.error); 102 | }, []); 103 | 104 | function onMouseMove(e) { 105 | if (!presence) return; 106 | presence.set( 107 | 'position', 108 | { 109 | x: e.clientX, 110 | y: e.clientY, 111 | }, 112 | 1 113 | ); 114 | } 115 | 116 | const values = Object.entries(positions); 117 | 118 | return ( 119 |
120 |
125 | Hello! This demo works better with friends. Share the link with someone! 126 |
127 | {values.map(([guest, pos]) => { 128 | if (guest === me) return; 129 | return ; 130 | })} 131 |
132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /example/pages/infinite-loop.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'; 2 | import RoomService from '../../dist'; 3 | 4 | const rs = new RoomService({ 5 | auth: '/api/hello', 6 | }); 7 | 8 | function useRoom(name: string): any { 9 | const [room, setRoom] = useState(); 10 | 11 | useEffect(() => { 12 | async function load() { 13 | const room = await rs.room(name); 14 | setRoom(room as any); 15 | } 16 | load(); 17 | }, []); 18 | 19 | return room; 20 | } 21 | 22 | export default function Home() { 23 | const room = useRoom('loopin'); 24 | 25 | function onChange(e) { 26 | if (!room) return; 27 | const map = room.map('loop'); 28 | room.subscribe(map, (m) => { 29 | console.log('what'); 30 | m.set('name', e.target.value); 31 | }); 32 | map.set('name', e.target.value); 33 | } 34 | 35 | return ; 36 | } 37 | -------------------------------------------------------------------------------- /example/pages/lists.tsx: -------------------------------------------------------------------------------- 1 | import { RoomService, ListClient } from '@roomservice/browser'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | function useList( 5 | roomName: string, 6 | listName: string 7 | ): [Array, ListClient] { 8 | const [list, setList] = useState>(); 9 | const [json, setJSON] = useState([]); 10 | 11 | useEffect(() => { 12 | async function load() { 13 | const client = new RoomService({ 14 | auth: '/api/hello', 15 | }); 16 | const room = await client.room(roomName); 17 | const l = room.list(listName); 18 | setList(l); 19 | setJSON(l.toArray()); 20 | 21 | room.subscribe(l, (arr) => { 22 | setJSON(arr); 23 | }); 24 | } 25 | load(); 26 | }, []); 27 | 28 | return [json, list]; 29 | } 30 | 31 | export default function List() { 32 | const [value, list] = useList('lists17', 'todos'); 33 | const [text, setText] = useState(''); 34 | 35 | function onCheckOff(i: number) { 36 | if (!list) return; 37 | list.delete(i); 38 | } 39 | 40 | function onEnterPress() { 41 | if (!list) return; 42 | list.push(text); 43 | setText(''); 44 | } 45 | 46 | return ( 47 |
48 |

Todos

49 | setText(e.target.value)} 53 | onKeyPress={(e) => { 54 | if (e.key === 'Enter') { 55 | onEnterPress(); 56 | } 57 | }} 58 | /> 59 | {value.map((l, i) => ( 60 |

onCheckOff(i)} 64 | > 65 | {l.object || l} 66 | {'-'} 67 | {i} 68 |

69 | ))} 70 | 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /example/pages/local-subs.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'; 2 | import RoomService from '../../dist'; 3 | 4 | const rs = new RoomService({ 5 | auth: '/api/hello', 6 | }); 7 | 8 | function useRoom(name: string): any { 9 | const [room, setRoom] = useState(); 10 | 11 | useEffect(() => { 12 | async function load() { 13 | const room = await rs.room(name); 14 | setRoom(room as any); 15 | } 16 | load(); 17 | }, []); 18 | 19 | return room; 20 | } 21 | 22 | function Input(props) { 23 | const room = useRoom(props.roomName); 24 | 25 | function onChange(e) { 26 | if (!room) return; 27 | room.map(props.mapName).set('name', e.target.value); 28 | } 29 | 30 | return ; 31 | } 32 | 33 | function ViewPort(props) { 34 | const [state, setState] = useState({ name: '' }); 35 | const room = useRoom(props.roomName); 36 | const counts = useRef(0); 37 | 38 | useEffect(() => { 39 | if (!room) return; 40 | const map = room.map(props.mapName); 41 | setState(map.toObject()); 42 | room.subscribe(map, (json) => { 43 | counts.current++; 44 | console.log(counts.current); 45 | setState(json); 46 | }); 47 | }, [room]); 48 | 49 | return
{state.name || ''}
; 50 | } 51 | 52 | export default function Home() { 53 | return ( 54 |
60 |
65 | 66 |
67 | 68 |
69 |
74 | 75 |
76 | 77 |
78 |
83 | 84 |
85 | 86 |
87 |
92 | 93 |
94 | 95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /example/pages/presence.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import RoomService, { RoomClient } from '../../dist'; 3 | 4 | const useInterval = (callback, delay) => { 5 | const savedCallback = useRef() as any; 6 | 7 | useEffect(() => { 8 | savedCallback.current = callback; 9 | }, [callback]); 10 | 11 | useEffect(() => { 12 | function tick() { 13 | savedCallback.current(); 14 | } 15 | if (delay !== null) { 16 | let id = setInterval(tick, delay); 17 | return () => clearInterval(id); 18 | } 19 | }, [delay]); 20 | }; 21 | 22 | const rs = new RoomService({ 23 | auth: '/api/hello', 24 | }); 25 | 26 | export default function Presence() { 27 | const [room, setRoom] = useState(); 28 | const [first, setFirst] = useState({}); 29 | const [second, setSecond] = useState({}); 30 | 31 | useEffect(() => { 32 | async function load() { 33 | const room = await rs.room('presence-demo'); 34 | const first = room.presence('first'); 35 | const second = room.presence('second'); 36 | setRoom(room); 37 | 38 | room.subscribe(first, (val) => { 39 | setFirst(val); 40 | }); 41 | 42 | room.subscribe(second, (val) => { 43 | setSecond(val); 44 | }); 45 | } 46 | load(); 47 | }, []); 48 | 49 | useInterval(() => { 50 | if (room === undefined) return; 51 | if (room) { 52 | room?.presence('first').set(new Date().toTimeString()); 53 | room?.presence('second').set(new Date().toTimeString()); 54 | } 55 | }, 1000); 56 | 57 | return ( 58 |
59 | hai 60 |

{JSON.stringify(first)}

61 |

{JSON.stringify(second)}

62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getroomservice/browser/a61a250e54a456be4da3511d9e2c2be6c3010f30/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /example/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 6 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 7 | } 8 | 9 | .window { 10 | height: 100vh; 11 | width: 100%; 12 | } 13 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /misc/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 3 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.0.4", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "sideEffects": false, 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "start": "tsdx watch", 12 | "build": "tsdx build", 13 | "test": "tsdx test", 14 | "lint": "tsdx lint", 15 | "prepare": "tsdx build", 16 | "fmt": "prettier -w ." 17 | }, 18 | "peerDependencies": {}, 19 | "prettier": { 20 | "printWidth": 80, 21 | "semi": true, 22 | "singleQuote": true, 23 | "trailingComma": "es5" 24 | }, 25 | "name": "@roomservice/browser", 26 | "author": "Evan Conrad", 27 | "module": "dist/browser.esm.js", 28 | "devDependencies": { 29 | "@types/invariant": "^2.2.30", 30 | "@types/jest": "^25.1.3", 31 | "@types/node": "^12.12.17", 32 | "@types/safe-json-stringify": "^1.1.0", 33 | "@types/socket.io-client": "^1.4.32", 34 | "@types/uuid": "^3.4.6", 35 | "jest": "^24.9.0", 36 | "prettier": "^2.1.2", 37 | "ts-jest": "^24.2.0", 38 | "tsdx": "^0.12.3", 39 | "tslib": "^1.11.1", 40 | "typescript": "latest" 41 | }, 42 | "dependencies": { 43 | "@roomservice/core": "0.3.2", 44 | "tiny-invariant": "^1.1.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ListClient.test.ts: -------------------------------------------------------------------------------- 1 | import { LocalBus } from './localbus'; 2 | import { InnerListClient } from './ListClient'; 3 | import { DocumentCheckpoint, Prop } from './types'; 4 | import { WebSocketDocCmdMessage } from './wsMessages'; 5 | 6 | describe('list clients', () => { 7 | const checkpoint: DocumentCheckpoint = { 8 | actors: {}, 9 | api_version: 0, 10 | id: '123', 11 | vs: 'AAAAOTKy5nUAAA==', 12 | index: 0, 13 | lists: { 14 | list: { 15 | afters: [], 16 | ids: [], 17 | values: [], 18 | }, 19 | }, 20 | maps: {}, 21 | }; 22 | const roomID = 'room'; 23 | const docID = 'doc'; 24 | const listID = 'list'; 25 | const send = jest.fn(); 26 | const ws = { send }; 27 | 28 | test("List clients don't include extra quotes", () => { 29 | const alpha = new InnerListClient({ 30 | checkpoint, 31 | roomID, 32 | docID, 33 | listID, 34 | ws, 35 | actor: 'alpha', 36 | bus: new LocalBus(), 37 | }); 38 | 39 | const finishedAlpha = alpha.push('"1"').push('2').push(3).push(''); 40 | 41 | expect(finishedAlpha.toArray()).toEqual(['"1"', '2', 3, '']); 42 | }); 43 | 44 | test('list clients can map over items', () => { 45 | const alpha = new InnerListClient({ 46 | checkpoint, 47 | roomID, 48 | docID, 49 | listID, 50 | ws, 51 | actor: 'alpha', 52 | bus: new LocalBus(), 53 | }); 54 | 55 | const finished = alpha.push(1).push({ x: 20, y: 30 }).push(3).push('cats'); 56 | 57 | const session = alpha.session(); 58 | 59 | expect(finished.map((val, i, key) => [val, i, key])).toEqual([ 60 | [1, 0, `0:${session}`], 61 | [{ x: 20, y: 30 }, 1, `1:${session}`], 62 | [3, 2, `2:${session}`], 63 | ['cats', 3, `3:${session}`], 64 | ]); 65 | }); 66 | 67 | test('list.push supports varags', () => { 68 | const alpha = new InnerListClient({ 69 | checkpoint, 70 | roomID, 71 | docID, 72 | listID, 73 | ws, 74 | actor: 'alpha', 75 | bus: new LocalBus(), 76 | }); 77 | 78 | const finished = alpha.push(1, 2, 'foo'); 79 | expect(finished.toArray()).toEqual([1, 2, 'foo']); 80 | }); 81 | 82 | test('List Clients send stuff to websockets', () => { 83 | const send = jest.fn(); 84 | const ws = { send }; 85 | 86 | const alpha = new InnerListClient({ 87 | checkpoint: checkpoint, 88 | roomID: roomID, 89 | docID, 90 | listID, 91 | ws, 92 | actor: 'alpha', 93 | bus: new LocalBus(), 94 | }); 95 | alpha.push('cats'); 96 | 97 | const body = send.mock.calls[0][1] as Prop; 98 | expect(body.args).toEqual([ 99 | 'lins', 100 | 'doc', 101 | 'list', 102 | 'root', 103 | `0:${alpha.session()}`, 104 | '"cats"', 105 | ]); 106 | }); 107 | 108 | test('List Clients add stuff to the end of the list', () => { 109 | const send = jest.fn(); 110 | const ws = { send }; 111 | 112 | let alpha = new InnerListClient({ 113 | checkpoint, 114 | roomID, 115 | docID, 116 | listID, 117 | ws, 118 | actor: 'alpha', 119 | bus: new LocalBus(), 120 | }); 121 | alpha = alpha.push('cats'); 122 | alpha = alpha.dangerouslyUpdateClientDirectly( 123 | ['lins', 'doc', 'list', `0:${alpha.session()}`, '0:bob', '"dogs"'], 124 | btoa('1'), 125 | false 126 | ); 127 | alpha = alpha.push('birds'); 128 | alpha = alpha.push('lizards'); 129 | alpha = alpha.push('blizzards'); 130 | 131 | expect(alpha.toArray()).toEqual([ 132 | 'cats', 133 | 'dogs', 134 | 'birds', 135 | 'lizards', 136 | 'blizzards', 137 | ]); 138 | }); 139 | 140 | test('List Clients add stuff to the end of the list in the fixture case', () => { 141 | const fixture = { 142 | type: 'result', 143 | body: { 144 | id: '1f87412b-d411-49ad-a58b-a1464c15959c', 145 | index: 6, 146 | api_version: 0, 147 | vs: 'AAAAOTKy5nUAAA==', 148 | actors: { 149 | '0': 'gst_b355e9c9-f1d3-4233-a6c5-e75e1cd0e52c', 150 | '1': 'gst_b2b6d556-6d0a-4862-b196-6a6e4aa2ff33', 151 | }, 152 | lists: { 153 | todo: { 154 | ids: ['0:0', '1:1', '1:0', '2:1'], 155 | afters: ['root', '0:0', '0:0', '1:1'], 156 | values: ['"okay"', '"alright cool"', '"right"', '"left"'], 157 | }, 158 | }, 159 | maps: { root: {} }, 160 | }, 161 | }; 162 | 163 | const send = jest.fn(); 164 | const ws = { send }; 165 | 166 | let alpha = new InnerListClient({ 167 | checkpoint: fixture.body, 168 | roomID, 169 | docID: fixture.body.id, 170 | listID: 'todo', 171 | ws, 172 | actor: 'gst_b355e9c9-f1d3-4233-a6c5-e75e1cd0e52c', 173 | bus: new LocalBus(), 174 | }); 175 | 176 | // Sanity check our import is correct 177 | expect(alpha.toArray()).toEqual(['okay', 'alright cool', 'left', 'right']); 178 | 179 | alpha = alpha.push('last'); 180 | 181 | // Assume we just added something to our understand of the end of the list 182 | expect(alpha.toArray()).toEqual([ 183 | 'okay', 184 | 'alright cool', 185 | 'left', 186 | 'right', 187 | 'last', 188 | ]); 189 | }); 190 | 191 | test('List Clients insertAt correctly', () => { 192 | const l = new InnerListClient({ 193 | checkpoint, 194 | roomID, 195 | docID, 196 | listID, 197 | ws, 198 | actor: 'me', 199 | bus: new LocalBus(), 200 | }); 201 | 202 | const finished = l.insertAt(0, 'c').insertAt(0, 'a').insertAt(1, 'b'); 203 | 204 | expect(finished.toArray()).toEqual(['a', 'b', 'c']); 205 | }); 206 | 207 | test('set undefined == delete', () => { 208 | const l = new InnerListClient({ 209 | checkpoint, 210 | roomID, 211 | docID, 212 | listID, 213 | ws, 214 | actor: 'me', 215 | bus: new LocalBus(), 216 | }); 217 | 218 | l.insertAt(0, 'a'); 219 | l.set(0, undefined); 220 | 221 | expect(l.toArray()).toEqual([]); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /src/ListClient.ts: -------------------------------------------------------------------------------- 1 | import { SuperlumeSend } from './ws'; 2 | import { ObjectClient, DocumentCheckpoint } from './types'; 3 | import invariant from 'tiny-invariant'; 4 | import { LocalBus } from './localbus'; 5 | import { ListInterpreter, ListMeta, ListStore } from '@roomservice/core'; 6 | import { BootstrapState } from './remote'; 7 | 8 | export type ListObject = Array; 9 | 10 | export class InnerListClient implements ObjectClient { 11 | private roomID: string; 12 | private ws: SuperlumeSend; 13 | private bus: LocalBus; 14 | private actor: string; 15 | private store: ListStore; 16 | private meta: ListMeta; 17 | 18 | id: string; 19 | 20 | constructor(props: { 21 | checkpoint: DocumentCheckpoint; 22 | roomID: string; 23 | docID: string; 24 | listID: string; 25 | ws: SuperlumeSend; 26 | actor: string; 27 | bus: LocalBus<{ args: string[]; from: string }>; 28 | }) { 29 | this.roomID = props.roomID; 30 | this.ws = props.ws; 31 | this.bus = props.bus; 32 | this.actor = props.actor; 33 | this.id = props.listID; 34 | 35 | const { meta, store } = ListInterpreter.newList(props.docID, props.listID); 36 | this.meta = meta; 37 | this.store = store; 38 | 39 | invariant( 40 | props.checkpoint.lists[props.listID], 41 | `Unknown listid '${props.listID}' in checkpoint.` 42 | ); 43 | 44 | ListInterpreter.importFromRawCheckpoint( 45 | this.store, 46 | this.actor, 47 | props.checkpoint, 48 | this.meta.listID 49 | ); 50 | } 51 | 52 | bootstrap(actor: string, checkpoint: BootstrapState) { 53 | this.actor = actor; 54 | ListInterpreter.importFromRawCheckpoint( 55 | this.store, 56 | this.actor, 57 | checkpoint.document, 58 | this.meta.listID 59 | ); 60 | } 61 | 62 | private sendCmd(cmd: string[]) { 63 | this.ws.send('doc:cmd', { 64 | room: this.roomID, 65 | args: cmd, 66 | }); 67 | 68 | this.bus.publish({ 69 | args: cmd, 70 | from: this.actor, 71 | }); 72 | } 73 | 74 | private clone(): InnerListClient { 75 | const cl = Object.assign( 76 | Object.create(Object.getPrototypeOf(this)), 77 | this 78 | ) as InnerListClient; 79 | return cl; 80 | } 81 | 82 | dangerouslyUpdateClientDirectly( 83 | cmd: string[], 84 | versionstamp: string, 85 | ack: boolean 86 | ): InnerListClient { 87 | ListInterpreter.validateCommand(this.meta, cmd); 88 | ListInterpreter.applyCommand(this.store, cmd, versionstamp, ack); 89 | return this.clone(); 90 | } 91 | 92 | get(index: K): T[K] | undefined { 93 | return ListInterpreter.get(this.store, index as any); 94 | } 95 | 96 | set(index: K, val: T[K]): InnerListClient { 97 | if (val === undefined) { 98 | return this.delete(index); 99 | } 100 | const cmd = ListInterpreter.runSet( 101 | this.store, 102 | this.meta, 103 | index as any, 104 | val 105 | ); 106 | 107 | // Remote 108 | this.sendCmd(cmd); 109 | 110 | return this.clone(); 111 | } 112 | 113 | delete(index: K): InnerListClient { 114 | const cmd = ListInterpreter.runDelete(this.store, this.meta, index as any); 115 | if (!cmd) { 116 | return this.clone(); 117 | } 118 | 119 | // Remote 120 | this.sendCmd(cmd); 121 | 122 | return this.clone(); 123 | } 124 | 125 | insertAfter(index: K, val: T[K]): InnerListClient { 126 | return this.insertAt((index as number) + 1, val); 127 | } 128 | 129 | insertAt(index: K, val: T[K]): InnerListClient { 130 | const cmd = ListInterpreter.runInsertAt( 131 | this.store, 132 | this.meta, 133 | index as number, 134 | val 135 | ); 136 | 137 | // Remote 138 | this.sendCmd(cmd); 139 | 140 | return this.clone(); 141 | } 142 | 143 | push(...args: Array): InnerListClient { 144 | const cmds = ListInterpreter.runPush(this.store, this.meta, ...args); 145 | 146 | for (let cmd of cmds) { 147 | this.sendCmd(cmd); 148 | } 149 | 150 | return this as InnerListClient; 151 | } 152 | 153 | map( 154 | fn: (val: T[K], index: number, key: string) => Array 155 | ): Array { 156 | return ListInterpreter.map(this.store, fn); 157 | } 158 | 159 | toArray(): T[number][] { 160 | return ListInterpreter.toArray(this.store); 161 | } 162 | 163 | // exposed for testing 164 | session(): string { 165 | return this.store.rt.session; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/MapClient.test.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from './types'; 2 | import { LocalBus } from './localbus'; 3 | import { InnerMapClient } from './MapClient'; 4 | import { WebSocketDocCmdMessage } from './wsMessages'; 5 | 6 | describe('InnerMapClient', () => { 7 | const send = jest.fn(); 8 | const ws = { send }; 9 | 10 | const map = new InnerMapClient({ 11 | // @ts-ignore 12 | checkpoint: { 13 | maps: {}, 14 | }, 15 | roomID: 'room', 16 | docID: 'doc', 17 | mapID: 'map', 18 | ws, 19 | bus: new LocalBus(), 20 | actor: 'actor', 21 | }); 22 | 23 | test('has the correct id', () => { 24 | expect(map.id).toEqual('map'); 25 | }); 26 | 27 | test('sends mput strings', () => { 28 | map.set('name', 'alice'); 29 | const body = send.mock.calls[0][1] as Prop; 30 | expect(body.args).toEqual(['mput', 'doc', 'map', 'name', '"alice"']); 31 | expect(map.get('name')).toEqual('alice'); 32 | }); 33 | 34 | test('sends mput numbers', () => { 35 | map.set('dogs', 2); 36 | const body = send.mock.calls[1][1] as Prop; 37 | expect(body.args).toEqual(['mput', 'doc', 'map', 'dogs', '2']); 38 | expect(map.get('dogs')).toEqual(2); 39 | }); 40 | 41 | test('sends mdel', () => { 42 | map.delete('dogs'); 43 | const body = send.mock.calls[2][1] as Prop; 44 | expect(body.args).toEqual(['mdel', 'doc', 'map', 'dogs']); 45 | expect(map.get('dogs')).toBeFalsy(); 46 | }); 47 | 48 | test('interprets mput', () => { 49 | map.dangerouslyUpdateClientDirectly( 50 | ['mput', 'doc', 'map', 'cats', 'smiles'], 51 | btoa('1'), 52 | false 53 | ); 54 | expect(map.get('cats')).toEqual('smiles'); 55 | }); 56 | 57 | test('interprets mdel', () => { 58 | map.dangerouslyUpdateClientDirectly( 59 | ['mdel', 'doc', 'map', 'cats'], 60 | btoa('1'), 61 | false 62 | ); 63 | expect(map.get('cats')).toBeFalsy(); 64 | }); 65 | 66 | test('interprets mput', () => { 67 | const val = map.set('dogs', 'good').set('snakes', 'snakey').toObject(); 68 | 69 | expect(val).toEqual({ 70 | dogs: 'good', 71 | name: 'alice', 72 | snakes: 'snakey', 73 | }); 74 | }); 75 | 76 | test('immediately hides deleted keys', () => { 77 | map.set('k', 'v'); 78 | map.delete('k'); 79 | 80 | expect(map.get('k')).toBeUndefined(); 81 | expect(map.keys.find((s) => s === 'k')).toBeUndefined(); 82 | expect(map.toObject()['k']).toBeUndefined(); 83 | }); 84 | 85 | test('set undefined == delete', () => { 86 | map.set('k', 'v'); 87 | map.set('k', undefined); 88 | 89 | expect(map.toObject()['k']).toBeUndefined(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/MapClient.ts: -------------------------------------------------------------------------------- 1 | import { ObjectClient } from './types'; 2 | import { SuperlumeSend } from './ws'; 3 | import { LocalBus } from './localbus'; 4 | import { 5 | MapMeta, 6 | MapStore, 7 | MapInterpreter, 8 | DocumentCheckpoint, 9 | } from '@roomservice/core'; 10 | import { BootstrapState } from './remote'; 11 | import { MapClient } from 'RoomClient'; 12 | 13 | export type MapObject = { [key: string]: any }; 14 | 15 | export class InnerMapClient implements ObjectClient { 16 | private roomID: string; 17 | private ws: SuperlumeSend; 18 | 19 | private meta: MapMeta; 20 | private store: MapStore; 21 | private bus: LocalBus; 22 | private actor: string; 23 | 24 | constructor(props: { 25 | checkpoint: DocumentCheckpoint; 26 | roomID: string; 27 | docID: string; 28 | mapID: string; 29 | actor: string; 30 | ws: SuperlumeSend; 31 | bus: LocalBus<{ from: string; args: string[] }>; 32 | }) { 33 | this.roomID = props.roomID; 34 | this.ws = props.ws; 35 | this.bus = props.bus; 36 | this.actor = props.actor; 37 | 38 | const { store, meta } = MapInterpreter.newMap(props.docID, props.mapID); 39 | this.store = store; 40 | this.meta = meta; 41 | 42 | //TODO: defer initial bootstrap? 43 | MapInterpreter.importFromRawCheckpoint( 44 | this.store, 45 | props.checkpoint, 46 | this.meta.mapID 47 | ); 48 | } 49 | 50 | public bootstrap(actor: string, checkpoint: BootstrapState) { 51 | this.actor = actor; 52 | MapInterpreter.importFromRawCheckpoint( 53 | this.store, 54 | checkpoint.document, 55 | this.meta.mapID 56 | ); 57 | } 58 | 59 | public get id(): string { 60 | return this.meta.mapID; 61 | } 62 | 63 | private sendCmd(cmd: string[]) { 64 | this.ws.send('doc:cmd', { 65 | room: this.roomID, 66 | args: cmd, 67 | }); 68 | 69 | this.bus.publish({ 70 | from: this.actor, 71 | args: cmd, 72 | }); 73 | } 74 | 75 | private clone(): InnerMapClient { 76 | return Object.assign( 77 | Object.create(Object.getPrototypeOf(this)), 78 | this 79 | ) as InnerMapClient; 80 | } 81 | 82 | dangerouslyUpdateClientDirectly( 83 | cmd: string[], 84 | versionstamp: string, 85 | ack: boolean 86 | ): InnerMapClient { 87 | MapInterpreter.validateCommand(this.meta, cmd); 88 | MapInterpreter.applyCommand(this.store, cmd, versionstamp, ack); 89 | return this.clone(); 90 | } 91 | 92 | get keys(): Array { 93 | return Array.from(this.store.kv.entries()) 94 | .filter(([_k, v]) => v.value !== undefined) 95 | .map(([k, _v]) => k); 96 | } 97 | 98 | get(key: K): T[K] | undefined { 99 | return this.store.kv.get(key as any)?.value; 100 | } 101 | 102 | set(key: K, value: T[K]): MapClient { 103 | if (value === undefined) { 104 | return this.delete(key); 105 | } 106 | 107 | const cmd = MapInterpreter.runSet(this.store, this.meta, key as any, value); 108 | 109 | // Remote 110 | this.sendCmd(cmd); 111 | 112 | return this.clone(); 113 | } 114 | 115 | toObject(): T { 116 | const obj = {} as any; 117 | for (let key of this.keys) { 118 | obj[key] = this.get(key); 119 | } 120 | return obj; 121 | } 122 | 123 | delete(key: K): MapClient { 124 | const cmd = MapInterpreter.runDelete(this.store, this.meta, key as any); 125 | 126 | // remote 127 | this.sendCmd(cmd); 128 | 129 | return this.clone(); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/PresenceClient.ts: -------------------------------------------------------------------------------- 1 | import { SuperlumeSend } from './ws'; 2 | import { PresenceCheckpoint, Prop } from './types'; 3 | import { 4 | WebSocketPresenceFwdMessage, 5 | WebSocketLeaveMessage, 6 | } from './wsMessages'; 7 | import { throttleByFirstArgument } from './throttle'; 8 | import { LocalBus } from './localbus'; 9 | import { BootstrapState } from './remote'; 10 | 11 | export type LocalPresenceUpdate = { 12 | key: string; 13 | valuesByUser: { [key: string]: any }; 14 | }; 15 | 16 | type ValuesByUser = { [key: string]: T }; 17 | 18 | export class InnerPresenceClient { 19 | private roomID: string; 20 | private ws: SuperlumeSend; 21 | private actor: string; 22 | private cache: PresenceCheckpoint; 23 | private sendPres: (key: string, args: any) => any; 24 | private bus: LocalBus; 25 | key: string; 26 | 27 | constructor(props: { 28 | roomID: string; 29 | checkpoint: BootstrapState; 30 | ws: SuperlumeSend; 31 | actor: string; 32 | key: string; 33 | bus: LocalBus; 34 | }) { 35 | this.roomID = props.roomID; 36 | this.ws = props.ws; 37 | this.actor = props.actor; 38 | this.key = props.key; 39 | this.cache = {}; 40 | this.bus = props.bus; 41 | 42 | const sendPres = (_: string, args: any) => { 43 | this.ws.send('presence:cmd', args); 44 | }; 45 | this.sendPres = throttleByFirstArgument(sendPres, 40); 46 | 47 | this.bootstrap(this.actor, props.checkpoint); 48 | } 49 | 50 | bootstrap(actor: string, checkpoint: BootstrapState) { 51 | this.actor = actor; 52 | 53 | this.cache = { 54 | ...this.cache, 55 | ...(checkpoint.presence[this.key] || {}), 56 | }; 57 | } 58 | 59 | /** 60 | * Gets all values for the presence key this client was created with, 61 | * organized by user id. 62 | */ 63 | getAll(): ValuesByUser { 64 | return this.withoutExpired(); 65 | } 66 | 67 | /** 68 | * Gets the current user's value. 69 | */ 70 | getMine(): T | undefined { 71 | return (this.cache || {})[this.actor]?.value; 72 | } 73 | 74 | private withoutExpired(): ValuesByUser { 75 | const result = {} as ValuesByUser; 76 | for (let actor in this.cache) { 77 | const obj = this.cache[actor]; 78 | 79 | if (new Date() > obj.expAt) { 80 | delete this.cache[actor]; 81 | continue; 82 | } 83 | result[actor] = obj.value; 84 | } 85 | 86 | return result; 87 | } 88 | 89 | private withoutActorOrExpired(actor: string): ValuesByUser { 90 | const result = {} as ValuesByUser; 91 | for (let a in this.cache) { 92 | const obj = this.cache[a]; 93 | if (!obj) continue; 94 | 95 | // remove this actor 96 | if (a === actor && this.cache[a]) { 97 | delete this.cache[a]; 98 | continue; 99 | } 100 | 101 | // Remove expired 102 | if (new Date() > obj.expAt) { 103 | delete this.cache[a]; 104 | continue; 105 | } 106 | 107 | result[a] = obj.value; 108 | } 109 | return result; 110 | } 111 | 112 | private myExpirationHandle?: NodeJS.Timeout; 113 | 114 | /** 115 | * @param value Any arbitrary object, string, boolean, or number. 116 | * @param exp (Optional) Expiration time in seconds 117 | */ 118 | set(value: T, exp?: number): { [key: string]: T } { 119 | let addition = exp ? exp : 60; 120 | // Convert to unix + add seconds 121 | const expAt = Math.round(new Date().getTime() / 1000) + addition; 122 | 123 | if (this.myExpirationHandle) { 124 | clearTimeout(this.myExpirationHandle); 125 | this.myExpirationHandle = undefined; 126 | } 127 | 128 | this.myExpirationHandle = setTimeout(() => { 129 | // TODO: this should be revisited when presence ACKs are added 130 | delete this.cache[this.actor]; 131 | this.bus.publish({ key: this.key, valuesByUser: this.withoutExpired() }); 132 | }, addition * 1000); 133 | 134 | this.sendPres(this.key, { 135 | room: this.roomID, 136 | key: this.key, 137 | value: JSON.stringify(value), 138 | expAt: expAt, 139 | }); 140 | 141 | this.cache[this.actor] = { 142 | value, 143 | expAt: new Date(expAt * 1000), 144 | }; 145 | 146 | const result = this.withoutExpired(); 147 | this.bus.publish({ key: this.key, valuesByUser: result }); 148 | 149 | return result; 150 | } 151 | 152 | dangerouslyUpdateClientDirectly( 153 | type: 'room:rm_guest', 154 | body: Prop 155 | ): { 156 | [key: string]: any; 157 | }; 158 | dangerouslyUpdateClientDirectly( 159 | type: 'presence:fwd', 160 | body: Prop 161 | ): { 162 | [key: string]: any; 163 | }; 164 | dangerouslyUpdateClientDirectly( 165 | type: 'presence:expire', 166 | body: { key: string } 167 | ): { 168 | [key: string]: any; 169 | }; 170 | dangerouslyUpdateClientDirectly( 171 | type: 'room:rm_guest' | 'presence:fwd' | 'presence:expire', 172 | body: any 173 | ): 174 | | { 175 | [key: string]: any; 176 | } 177 | | false { 178 | if (type === 'room:rm_guest') { 179 | return this.withoutActorOrExpired(body.guest); 180 | } 181 | if (type === 'presence:expire') { 182 | const foo = this.withoutExpired(); 183 | return foo; 184 | } 185 | 186 | if (body.room !== this.roomID) return false; 187 | // ignore validation msgs 188 | // TODO: use same ack logic as doc cmds 189 | if (body.from === this.actor) return false; 190 | 191 | const obj = { 192 | expAt: new Date(body.expAt * 1000), 193 | value: JSON.parse(body.value), 194 | }; 195 | 196 | this.cache[body.from] = obj; 197 | 198 | return this.withoutExpired(); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/RoomClient.test.ts: -------------------------------------------------------------------------------- 1 | import { RoomClient } from './RoomClient'; 2 | import { DocumentCheckpoint } from './types'; 3 | import { AuthBundle, BootstrapState, LocalSession } from './remote'; 4 | import { mockAuthBundle } from './remote.test'; 5 | 6 | export function mockSession(): LocalSession { 7 | return { 8 | token: 'mock token', 9 | guestReference: 'mock actor', 10 | docID: 'moc docID', 11 | roomID: 'moc roomID', 12 | }; 13 | } 14 | 15 | export function mockSessionFetch(_: { 16 | authBundle: AuthBundle; 17 | room: string; 18 | document: string; 19 | }): Promise { 20 | return Promise.resolve(mockSession()); 21 | } 22 | 23 | export function mockCheckpoint(): DocumentCheckpoint { 24 | return { 25 | maps: {}, 26 | lists: {}, 27 | id: 'mock checkpoint', 28 | index: 0, 29 | api_version: 0, 30 | vs: 'AAo=', 31 | actors: [], 32 | }; 33 | } 34 | 35 | export function mockBootstrapState(): BootstrapState { 36 | return { 37 | document: mockCheckpoint(), 38 | presence: {}, 39 | }; 40 | } 41 | 42 | function mockRoomClient(): RoomClient { 43 | const session = mockSession(); 44 | const bootstrapState = mockBootstrapState(); 45 | const authBundle = mockAuthBundle(); 46 | 47 | return new RoomClient({ 48 | auth: authBundle.strategy, 49 | authCtx: authBundle.ctx, 50 | session, 51 | wsURL: 'wss://websocket.invalid', 52 | docsURL: 'https://docs.invalid', 53 | presenceURL: 'https://presence.invalid', 54 | actor: 'me', 55 | bootstrapState, 56 | token: session.token, 57 | room: 'myRoom', 58 | document: 'default', 59 | }); 60 | } 61 | 62 | test('we catch infinite loops', () => { 63 | function thisThrows() { 64 | const client = mockRoomClient(); 65 | const m = client.map('mymap'); 66 | client.subscribe(m, () => { 67 | m.set('I', 'cause an infinite loop'); 68 | }); 69 | 70 | m.set('I', 'trigger the bad times'); 71 | } 72 | 73 | expect(thisThrows).toThrow(); 74 | }); 75 | -------------------------------------------------------------------------------- /src/RoomClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ForwardedMessageBody, 3 | ReconnectingWebSocket, 4 | WebsocketDispatch, 5 | } from './ws'; 6 | import { AuthStrategy, Prop } from './types'; 7 | import { 8 | fetchSession, 9 | LocalSession, 10 | BootstrapState, 11 | fetchBootstrapState, 12 | } from './remote'; 13 | import { InnerListClient, ListObject } from './ListClient'; 14 | import { InnerMapClient, MapObject } from './MapClient'; 15 | import { InnerPresenceClient, LocalPresenceUpdate } from './PresenceClient'; 16 | import invariant from 'tiny-invariant'; 17 | import { isOlderVS } from '@roomservice/core'; 18 | import { 19 | WebSocketDocFwdMessage, 20 | WebSocketLeaveMessage, 21 | WebSocketPresenceFwdMessage, 22 | WebSocketServerMessage, 23 | } from './wsMessages'; 24 | import { LocalBus } from './localbus'; 25 | import { PRESENCE_URL, WS_URL } from './constants'; 26 | import { DOCS_URL } from './constants'; 27 | 28 | type Listener = { 29 | event?: Prop; 30 | objID?: string; 31 | fn: (args: any) => void; 32 | }; 33 | 34 | const MAP_CMDS = ['mcreate', 'mput', 'mputref', 'mdel']; 35 | const LIST_CMDS = ['lcreate', 'lins', 'linsref', 'lput', 'lputref', 'ldel']; 36 | 37 | type ListenerBundle = Array; 38 | 39 | export type MapClient = Pick< 40 | InnerMapClient, 41 | 'get' | 'set' | 'delete' | 'toObject' | 'keys' 42 | >; 43 | 44 | export type ListClient = Pick< 45 | InnerListClient, 46 | 'insertAt' | 'insertAfter' | 'push' | 'set' | 'delete' | 'map' | 'toArray' 47 | >; 48 | 49 | export type PresenceClient = Pick< 50 | InnerPresenceClient, 51 | 'set' | 'getMine' | 'getAll' 52 | >; 53 | 54 | interface DispatchDocCmdMsg { 55 | args: string[]; 56 | from: string; 57 | } 58 | 59 | export class RoomClient implements WebsocketDispatch { 60 | private roomID: string; 61 | private docID: string; 62 | private actor: string; 63 | private bootstrapState: BootstrapState; 64 | 65 | private presenceClients: { [key: string]: InnerPresenceClient } = {}; 66 | private listClients: { [key: string]: InnerListClient } = {}; 67 | private mapClients: { [key: string]: InnerMapClient } = {}; 68 | private expiresByActorByKey: { 69 | [key: string]: { [key: string]: NodeJS.Timeout }; 70 | } = {}; 71 | 72 | private ws: ReconnectingWebSocket; 73 | 74 | constructor(params: { 75 | auth: AuthStrategy; 76 | authCtx: any; 77 | session: LocalSession; 78 | wsURL: string; 79 | docsURL: string; 80 | presenceURL: string; 81 | actor: string; 82 | bootstrapState: BootstrapState; 83 | token: string; 84 | room: string; 85 | document: string; 86 | }) { 87 | const { wsURL, docsURL, presenceURL, room, document } = params; 88 | this.ws = new ReconnectingWebSocket({ 89 | dispatcher: this, 90 | wsURL, 91 | docsURL, 92 | presenceURL, 93 | room, 94 | document, 95 | authBundle: { 96 | strategy: params.auth, 97 | ctx: params.authCtx, 98 | }, 99 | sessionFetch: (_) => { 100 | // TODO: implement re-fetching of sessions when stale 101 | return Promise.resolve(params.session); 102 | }, 103 | }); 104 | this.roomID = params.session.roomID; 105 | this.docID = params.bootstrapState.document.id; 106 | this.actor = params.actor; 107 | this.bootstrapState = params.bootstrapState; 108 | } 109 | 110 | // impl WebsocketDispatch 111 | forwardCmd(msgType: string, body: ForwardedMessageBody): void { 112 | if (this.queueIncomingCmds) { 113 | this.cmdQueue.push([msgType, body]); 114 | return; 115 | } 116 | this.processCmd(msgType, body); 117 | } 118 | 119 | processCmd(msgType: string, body: ForwardedMessageBody) { 120 | if (msgType == 'doc:fwd' && 'args' in body) { 121 | this.dispatchDocCmd(body); 122 | } 123 | if (msgType == 'presence:fwd' && 'expAt' in body) { 124 | this.dispatchPresenceCmd(body); 125 | } 126 | if (msgType == 'room:rm_guest' && 'guest' in body) { 127 | this.dispatchRmGuest(body); 128 | } 129 | } 130 | 131 | bootstrap(actor: string, state: BootstrapState): void { 132 | this.actor = actor; 133 | this.bootstrapState = state; 134 | 135 | for (const [_, client] of Object.entries(this.listClients)) { 136 | client.bootstrap(actor, state); 137 | } 138 | for (const [_, client] of Object.entries(this.mapClients)) { 139 | client.bootstrap(actor, state); 140 | } 141 | for (const [_, client] of Object.entries(this.presenceClients)) { 142 | client.bootstrap(actor, state); 143 | } 144 | 145 | this.queueIncomingCmds = false; 146 | for (const [msgType, body] of this.cmdQueue) { 147 | this.processCmd(msgType, body); 148 | } 149 | this.cmdQueue.length = 0; 150 | } 151 | 152 | private queueIncomingCmds: boolean = true; 153 | private cmdQueue: Array<[string, ForwardedMessageBody]> = []; 154 | 155 | startQueueingCmds(): void { 156 | this.queueIncomingCmds = true; 157 | } 158 | 159 | dispatchDocCmd(body: Prop) { 160 | if (body.room !== this.roomID) return; 161 | if (!body.args || body.args.length < 3) { 162 | // Potentially a network failure, we don't want to crash, 163 | // but do want to warn people 164 | console.error('Unexpected command: ', body.args); 165 | return; 166 | } 167 | // Ignore version stamps older than checkpoint 168 | if (isOlderVS(body.vs, this.bootstrapState.document.vs)) { 169 | return; 170 | } 171 | 172 | const [cmd, docID, objID] = [body.args[0], body.args[1], body.args[2]]; 173 | 174 | if (docID !== this.docID) return; 175 | 176 | if (MAP_CMDS.includes(cmd)) { 177 | this.dispatchMapCmd(objID, body); 178 | } else if (LIST_CMDS.includes(cmd)) { 179 | this.dispatchListCmd(objID, body); 180 | } else { 181 | console.warn( 182 | 'Unhandled Room Service doc:fwd command: ' + 183 | cmd + 184 | '. Consider updating the Room Service client.' 185 | ); 186 | } 187 | } 188 | 189 | dispatchRmGuest(body: Prop) { 190 | if (body.room !== this.roomID) return; 191 | for (const [key, presenceClient] of Object.entries(this.presenceClients)) { 192 | const newClient = presenceClient.dangerouslyUpdateClientDirectly( 193 | 'room:rm_guest', 194 | body 195 | ); 196 | if (!newClient) return; 197 | for (const cb of this.presenceCallbacksByKey[key] || []) { 198 | cb(newClient, body.guest); 199 | } 200 | } 201 | } 202 | 203 | private dispatchMapCmd( 204 | objID: string, 205 | body: Prop 206 | ) { 207 | if (!this.mapClients[objID]) { 208 | this.createMapLocally(objID); 209 | } 210 | 211 | const client = this.mapClients[objID]; 212 | const updatedClient = client.dangerouslyUpdateClientDirectly( 213 | body.args, 214 | body.vs, 215 | body.ack 216 | ); 217 | 218 | for (const cb of this.mapCallbacksByObjID[objID] || []) { 219 | cb(updatedClient.toObject(), body.from); 220 | } 221 | } 222 | 223 | private dispatchListCmd( 224 | objID: string, 225 | body: Prop 226 | ) { 227 | if (!this.listClients[objID]) { 228 | this.createListLocally(objID); 229 | } 230 | 231 | const client = this.listClients[objID]; 232 | const updatedClient = client.dangerouslyUpdateClientDirectly( 233 | body.args, 234 | body.vs, 235 | body.ack 236 | ); 237 | 238 | for (const cb of this.listCallbacksByObjID[objID] || []) { 239 | cb(updatedClient.toArray(), body.from); 240 | } 241 | } 242 | 243 | private dispatchPresenceCmd(body: Prop) { 244 | if (body.room !== this.roomID) return; 245 | // TODO: use same ack logic as doc cmds 246 | if (body.from === this.actor) return; 247 | 248 | const key = body.key; 249 | const client = this.presence(key) as InnerPresenceClient; 250 | 251 | const now = new Date().getTime() / 1000; 252 | const secondsTillTimeout = body.expAt - now; 253 | if (secondsTillTimeout < 0) { 254 | // don't show expired stuff 255 | return; 256 | } 257 | 258 | // Expire stuff if it's within a reasonable range (12h) 259 | if (secondsTillTimeout < 60 * 60 * 12) { 260 | const expiresByActor = this.expiresByActorByKey[key] || {}; 261 | const actor = body.from; 262 | if (expiresByActor[actor]) { 263 | clearTimeout(expiresByActor[actor]); 264 | } 265 | 266 | let timeout = setTimeout(() => { 267 | const newClient = client.dangerouslyUpdateClientDirectly( 268 | 'presence:expire', 269 | { key: body.key } 270 | ); 271 | if (!newClient) return; 272 | for (const cb of this.presenceCallbacksByKey[key] ?? []) { 273 | cb(newClient, body.from); 274 | } 275 | }, secondsTillTimeout * 1000); 276 | 277 | expiresByActor[actor] = timeout; 278 | this.expiresByActorByKey[key] = expiresByActor; 279 | } 280 | 281 | const newClient = client.dangerouslyUpdateClientDirectly( 282 | 'presence:fwd', 283 | body 284 | ); 285 | if (!newClient) return; 286 | for (const cb of this.presenceCallbacksByKey[key] ?? []) { 287 | cb(newClient, body.from); 288 | } 289 | } 290 | 291 | get me() { 292 | return this.actor; 293 | } 294 | 295 | private createListLocally(name: string) { 296 | const bus = new LocalBus(); 297 | bus.subscribe((body) => { 298 | const client = this.listClients[name]; 299 | for (const cb of this.listCallbacksByObjID[name] || []) { 300 | cb(client.toArray(), body.from); 301 | } 302 | }); 303 | 304 | const l = new InnerListClient({ 305 | checkpoint: this.bootstrapState.document, 306 | roomID: this.roomID, 307 | docID: this.docID, 308 | listID: name, 309 | ws: this.ws, 310 | actor: this.actor, 311 | bus, 312 | }); 313 | this.listClients[name] = l; 314 | return l; 315 | } 316 | 317 | list(name: string): ListClient { 318 | if (this.listClients[name]) { 319 | return this.listClients[name]; 320 | } 321 | 322 | // create a list if it doesn't exist 323 | if (!this.bootstrapState.document.lists[name]) { 324 | this.ws.send('doc:cmd', { 325 | args: ['lcreate', this.docID, name], 326 | room: this.roomID, 327 | }); 328 | 329 | // Assume success 330 | this.bootstrapState.document.lists[name] = { 331 | afters: [], 332 | ids: [], 333 | values: [], 334 | }; 335 | } 336 | 337 | return this.createListLocally(name); 338 | } 339 | 340 | private createMapLocally(name: string) { 341 | const bus = new LocalBus(); 342 | bus.subscribe((body) => { 343 | const client = this.mapClients[name]; 344 | for (const cb of this.mapCallbacksByObjID[name] || []) { 345 | cb(client.toObject(), body.from); 346 | } 347 | }); 348 | 349 | const m = new InnerMapClient({ 350 | checkpoint: this.bootstrapState.document, 351 | roomID: this.roomID, 352 | docID: this.docID, 353 | mapID: name, 354 | ws: this.ws, 355 | bus, 356 | actor: this.actor, 357 | }); 358 | this.mapClients[name] = m; 359 | return m; 360 | } 361 | 362 | map(name: string): MapClient { 363 | if (this.mapClients[name]) { 364 | return this.mapClients[name] as MapClient; 365 | } 366 | 367 | // Create this map if it doesn't exist 368 | if (!this.bootstrapState.document.maps[name]) { 369 | this.ws.send('doc:cmd', { 370 | args: ['mcreate', this.docID, name], 371 | room: this.roomID, 372 | }); 373 | } 374 | 375 | return this.createMapLocally(name); 376 | } 377 | 378 | presence(key: string): PresenceClient { 379 | if (this.presenceClients[key]) { 380 | return this.presenceClients[key]; 381 | } 382 | 383 | const bus = new LocalBus(); 384 | bus.subscribe((body) => { 385 | for (const cb of this.presenceCallbacksByKey[body.key] || []) { 386 | cb(body.valuesByUser, this.actor); 387 | } 388 | }); 389 | 390 | const p = new InnerPresenceClient({ 391 | checkpoint: this.bootstrapState, 392 | roomID: this.roomID, 393 | actor: this.actor, 394 | ws: this.ws, 395 | key, 396 | bus, 397 | }); 398 | 399 | try { 400 | this.presenceClients[key] = p; 401 | } catch (err) { 402 | throw new Error( 403 | `Don't Freeze State. See more: https://err.sh/getroomservice/browser/dont-freeze` 404 | ); 405 | } 406 | return this.presenceClients[key]; 407 | } 408 | 409 | private mapCallbacksByObjID: { [key: string]: Array } = {}; 410 | private listCallbacksByObjID: { [key: string]: Array } = {}; 411 | private presenceCallbacksByKey: { [key: string]: Array } = {}; 412 | 413 | subscribe( 414 | list: ListClient, 415 | onChangeFn: (list: T) => any 416 | ): ListenerBundle; 417 | subscribe( 418 | list: ListClient, 419 | onChangeFn: (list: T, from: string) => any 420 | ): ListenerBundle; 421 | subscribe( 422 | map: MapClient, 423 | onChangeFn: (map: T) => {} 424 | ): ListenerBundle; 425 | subscribe( 426 | map: MapClient, 427 | onChangeFn: (map: T, from: string) => any 428 | ): ListenerBundle; 429 | subscribe( 430 | presence: PresenceClient, 431 | onChangeFn: (obj: { [key: string]: T }, from: string) => any 432 | ): ListenerBundle; 433 | subscribe(obj: any, onChangeFn: Function): ListenerBundle { 434 | // Presence handler 435 | if (obj instanceof InnerPresenceClient) { 436 | return this.subscribePresence( 437 | obj, 438 | onChangeFn as (obj: { [key: string]: T }, from: string) => any 439 | ); 440 | } 441 | 442 | // create new closure so fns can be subscribed/unsubscribed multiple times 443 | const cb = ( 444 | obj: ListObject | MapObject | { [key: string]: T }, 445 | from: string 446 | ) => { 447 | onChangeFn(obj, from); 448 | }; 449 | 450 | let objID; 451 | if (obj instanceof InnerMapClient) { 452 | const client = obj as InnerMapClient; 453 | objID = client.id; 454 | this.mapCallbacksByObjID[objID] = this.mapCallbacksByObjID[objID] || []; 455 | this.mapCallbacksByObjID[objID].push(cb); 456 | } 457 | 458 | if (obj instanceof InnerListClient) { 459 | const client = obj as InnerListClient; 460 | objID = client.id; 461 | this.listCallbacksByObjID[objID] = this.listCallbacksByObjID[objID] || []; 462 | this.listCallbacksByObjID[objID].push(cb); 463 | } 464 | 465 | return [ 466 | { 467 | objID, 468 | fn: cb as (args: any) => void, 469 | }, 470 | ]; 471 | } 472 | 473 | private subscribePresence( 474 | obj: InnerPresenceClient, 475 | onChangeFn: ((obj: { [key: string]: T }, from: string) => any) | undefined 476 | ): ListenerBundle { 477 | invariant( 478 | obj, 479 | 'subscribe() expects the first argument to not be undefined.' 480 | ); 481 | 482 | // create new closure so fns can be subscribed/unsubscribed multiple times 483 | const cb = (obj: any, from: string) => { 484 | if (onChangeFn) { 485 | onChangeFn(obj, from); 486 | } 487 | }; 488 | 489 | const key = obj.key; 490 | 491 | this.presenceCallbacksByKey[key] = this.presenceCallbacksByKey[key] || []; 492 | this.presenceCallbacksByKey[key].push(cb); 493 | 494 | return [ 495 | { 496 | objID: key, 497 | fn: cb as (args: any) => void, 498 | }, 499 | ]; 500 | } 501 | 502 | unsubscribe(listeners: ListenerBundle) { 503 | for (let l of listeners) { 504 | if (l.objID) { 505 | this.mapCallbacksByObjID[l.objID] = removeCallback( 506 | this.mapCallbacksByObjID[l.objID], 507 | l.fn 508 | ); 509 | this.listCallbacksByObjID[l.objID] = removeCallback( 510 | this.listCallbacksByObjID[l.objID], 511 | l.fn 512 | ); 513 | this.presenceCallbacksByKey[l.objID] = removeCallback( 514 | this.presenceCallbacksByKey[l.objID], 515 | l.fn 516 | ); 517 | } 518 | } 519 | } 520 | } 521 | 522 | export async function createRoom(params: { 523 | docsURL: string; 524 | presenceURL: string; 525 | authStrategy: AuthStrategy; 526 | authCtx: A; 527 | room: string; 528 | document: string; 529 | }): Promise { 530 | const session = await fetchSession({ 531 | authBundle: { 532 | strategy: params.authStrategy, 533 | ctx: params.authCtx, 534 | }, 535 | room: params.room, 536 | document: params.document, 537 | }); 538 | 539 | const bootstrapState = await fetchBootstrapState({ 540 | docsURL: params.docsURL, 541 | presenceURL: params.presenceURL, 542 | token: session.token, 543 | docID: session.docID, 544 | roomID: session.roomID, 545 | }); 546 | const roomClient = new RoomClient({ 547 | actor: session.guestReference, 548 | bootstrapState, 549 | token: session.token, 550 | room: params.room, 551 | document: params.document, 552 | auth: params.authStrategy, 553 | authCtx: params.authCtx, 554 | wsURL: WS_URL, 555 | docsURL: DOCS_URL, 556 | presenceURL: PRESENCE_URL, 557 | session, 558 | }); 559 | 560 | return roomClient; 561 | } 562 | 563 | function removeCallback( 564 | cbs: Array | undefined, 565 | rmCb: Function 566 | ): Array { 567 | if (!cbs) { 568 | return []; 569 | } 570 | return cbs.filter((existingCb) => { 571 | return existingCb !== rmCb; 572 | }); 573 | } 574 | -------------------------------------------------------------------------------- /src/RoomServiceClient.ts: -------------------------------------------------------------------------------- 1 | import { DOCS_URL, PRESENCE_URL } from './constants'; 2 | import { createRoom, RoomClient } from './RoomClient'; 3 | import { AuthStrategy, AuthFunction } from './types'; 4 | 5 | interface SimpleAuthParams { 6 | auth: string; 7 | } 8 | 9 | interface ComplexAuthParams { 10 | auth: AuthFunction; 11 | ctx: T; 12 | } 13 | 14 | export type RoomServiceParameters = 15 | | SimpleAuthParams 16 | | ComplexAuthParams; 17 | 18 | export class RoomService { 19 | private auth: AuthStrategy; 20 | private ctx: T; 21 | private roomClients: { [key: string]: RoomClient } = {}; 22 | 23 | constructor(params: RoomServiceParameters) { 24 | this.auth = params.auth; 25 | this.ctx = (params as ComplexAuthParams).ctx || ({} as T); 26 | } 27 | 28 | async room(name: string): Promise { 29 | if (this.roomClients[name]) { 30 | return this.roomClients[name]; 31 | } 32 | 33 | const client = await createRoom({ 34 | docsURL: DOCS_URL, 35 | presenceURL: PRESENCE_URL, 36 | authStrategy: this.auth, 37 | authCtx: this.ctx, 38 | room: name, 39 | document: 'default', 40 | }); 41 | this.roomClients[name] = client; 42 | 43 | return client; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // export const WS_URL = 'ws://localhost:3452'; 2 | // export const DOCS_URL = 'http://localhost:3454/docs'; 3 | // export const PRESENCE_URL = 'http://localhost:3454/presence'; 4 | 5 | export const WS_URL = 'wss://super.roomservice.dev/ws'; 6 | export const DOCS_URL = 'https://super.roomservice.dev/docs'; 7 | export const PRESENCE_URL = 'https://super.roomservice.dev/presence'; 8 | -------------------------------------------------------------------------------- /src/errs.ts: -------------------------------------------------------------------------------- 1 | export const errNoInfiniteLoop = () => 2 | new Error( 3 | 'Infinite loop detected, see more: See more: https://err.sh/getroomservice/browser/infinite-loop' 4 | ); 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { RoomService, RoomServiceParameters } from './RoomServiceClient'; 2 | import { 3 | RoomClient, 4 | MapClient, 5 | ListClient, 6 | PresenceClient, 7 | } from './RoomClient'; 8 | 9 | export { 10 | RoomService, 11 | RoomClient, 12 | MapClient, 13 | ListClient, 14 | PresenceClient, 15 | RoomServiceParameters, 16 | }; 17 | 18 | export default RoomService; 19 | -------------------------------------------------------------------------------- /src/localbus.test.ts: -------------------------------------------------------------------------------- 1 | import { LocalBus } from './localbus'; 2 | 3 | test('localbus works in the simple case', () => { 4 | const bus = new LocalBus(); 5 | 6 | let pet = 'cats'; 7 | bus.subscribe((v) => { 8 | pet = v; 9 | }); 10 | bus.publish('dogs'); 11 | 12 | expect(pet).toEqual('dogs'); 13 | }); 14 | 15 | test('localbus can get rid of subscribers', () => { 16 | const bus = new LocalBus(); 17 | 18 | let pet = 'cats'; 19 | const unsub = bus.subscribe((v) => { 20 | pet = v; 21 | }); 22 | bus.unsubscribe(unsub); 23 | bus.publish('dogs'); // doesn't get applied 24 | 25 | expect(pet).toEqual('cats'); 26 | }); 27 | 28 | test('localbus can subscribe twice', () => { 29 | const bus = new LocalBus(); 30 | 31 | let pets = [] as string[]; 32 | bus.subscribe((v) => { 33 | pets.push(v); 34 | }); 35 | bus.subscribe((v) => { 36 | pets.push(v); 37 | }); 38 | 39 | bus.publish('dogs'); 40 | 41 | expect(pets).toEqual(['dogs', 'dogs']); 42 | }); 43 | -------------------------------------------------------------------------------- /src/localbus.ts: -------------------------------------------------------------------------------- 1 | import { errNoInfiniteLoop } from './errs'; 2 | 3 | // 🚌 4 | // Local pubsub, so that if you call .set in one place 5 | // it will trigger a .subscribe elsewhere, without 6 | // needing to go through the websockets 7 | export class LocalBus { 8 | private subs: Set<(msg: T) => void>; 9 | 10 | constructor() { 11 | this.subs = new Set<(msg: T) => void>(); 12 | } 13 | 14 | unsubscribe(fn: (msg: T) => void) { 15 | this.subs.delete(fn); 16 | } 17 | 18 | subscribe(fn: (msg: T) => void): (msg: T) => void { 19 | this.subs.add(fn); 20 | return fn; 21 | } 22 | 23 | private isPublishing: boolean = false; 24 | 25 | publish(msg: T) { 26 | // This is an infinite loop 27 | if (this.isPublishing) { 28 | throw errNoInfiniteLoop(); 29 | } 30 | 31 | this.isPublishing = true; 32 | this.subs.forEach((fn) => { 33 | fn(msg); 34 | }); 35 | this.isPublishing = false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/remote.test.ts: -------------------------------------------------------------------------------- 1 | import { AuthResponse } from './types'; 2 | import { AuthBundle, fetchSession } from './remote'; 3 | 4 | export function mockAuthBundle(): AuthBundle<{}> { 5 | const strategy = async (_: { 6 | room: string; 7 | ctx: {}; 8 | }): Promise => { 9 | return { 10 | resources: [ 11 | { 12 | reference: 'doc_123', 13 | permission: 'read_write', 14 | object: 'document', 15 | id: '123', 16 | }, 17 | { 18 | reference: 'room_123', 19 | permission: 'join', 20 | object: 'room', 21 | id: '123', 22 | }, 23 | ], 24 | token: 'some-token', 25 | user: 'some_user_id', 26 | }; 27 | }; 28 | 29 | return { 30 | strategy, 31 | ctx: {}, 32 | }; 33 | } 34 | 35 | test('Test fetchSession', async () => { 36 | const fetcher = jest.fn(() => { 37 | return { 38 | resources: [ 39 | { 40 | object: 'document', 41 | id: '123', 42 | }, 43 | { 44 | object: 'room', 45 | id: '123', 46 | }, 47 | ], 48 | token: 'some-token', 49 | user: { 50 | id: 'some_user_id', 51 | reference: 'my-user', 52 | }, 53 | }; 54 | }); 55 | 56 | await fetchSession({ 57 | authBundle: { strategy: fetcher as any, ctx: {} }, 58 | room: '123', 59 | document: '123', 60 | }); 61 | 62 | expect(fetcher.mock.calls[0]).toEqual([ 63 | { 64 | ctx: {}, 65 | room: '123', 66 | }, 67 | ]); 68 | }); 69 | -------------------------------------------------------------------------------- /src/remote.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Message, 3 | DocumentCheckpoint, 4 | PresenceCheckpoint, 5 | AuthStrategy, 6 | } from './types'; 7 | 8 | type AllPresence = { [key: string]: PresenceCheckpoint }; 9 | export interface BootstrapState { 10 | presence: AllPresence; 11 | document: DocumentCheckpoint; 12 | } 13 | 14 | export async function fetchBootstrapState(props: { 15 | docsURL: string; 16 | presenceURL: string; 17 | token: string; 18 | roomID: string; 19 | docID: string; 20 | }): Promise { 21 | const [allPresence, documentCheckpoint] = await Promise.all< 22 | AllPresence, 23 | DocumentCheckpoint 24 | >([ 25 | fetchPresence(props.presenceURL, props.token, props.roomID), 26 | fetchDocument(props.docsURL, props.token, props.docID), 27 | ]); 28 | 29 | return { 30 | presence: allPresence, 31 | document: documentCheckpoint, 32 | }; 33 | } 34 | 35 | export async function fetchPresence( 36 | url: string, 37 | token: string, 38 | roomID: string 39 | ): Promise { 40 | const res = await fetch(url + '/' + roomID, { 41 | headers: { 42 | Authorization: 'Bearer: ' + token, 43 | }, 44 | }); 45 | 46 | const doc = (await res.json()) as { [key: string]: PresenceCheckpoint }; 47 | 48 | // Parse JSON values 49 | for (let key of Object.keys(doc)) { 50 | for (let actor of Object.keys(doc[key])) { 51 | if (typeof doc[key][actor].value === 'string') { 52 | let json; 53 | try { 54 | json = JSON.parse(doc[key][actor].value as string); 55 | } catch (err) {} 56 | if (json) { 57 | doc[key][actor].value = json; 58 | } 59 | } 60 | } 61 | } 62 | 63 | return doc; 64 | } 65 | 66 | export async function fetchDocument( 67 | url: string, 68 | token: string, 69 | docID: string 70 | ): Promise { 71 | const res = await fetch(url + '/' + docID, { 72 | headers: { 73 | Authorization: 'Bearer: ' + token, 74 | }, 75 | }); 76 | 77 | const doc: Message = await res.json(); 78 | return doc.body; 79 | } 80 | 81 | export interface ServerSession { 82 | token: string; 83 | user: string; 84 | resources: Array<{ 85 | id: string; 86 | object: 'document' | 'room'; 87 | }>; 88 | } 89 | 90 | export interface LocalSession { 91 | token: string; 92 | guestReference: string; 93 | docID: string; 94 | roomID: string; 95 | } 96 | 97 | export interface AuthBundle { 98 | strategy: AuthStrategy; 99 | ctx: T; 100 | } 101 | 102 | export async function fetchSession(params: { 103 | authBundle: AuthBundle; 104 | room: string; 105 | document: string; 106 | }): Promise { 107 | const { 108 | authBundle: { strategy, ctx }, 109 | room, 110 | document, 111 | } = params; 112 | // A user defined function 113 | if (typeof strategy === 'function') { 114 | const result = await strategy({ 115 | room, 116 | ctx, 117 | }); 118 | if (!result.user) { 119 | throw new Error(`The auth function must return a 'user' key.`); 120 | } 121 | 122 | const docID = result.resources.find((r) => r.object === 'document')!.id; 123 | const roomID = result.resources.find((r) => r.object === 'room')!.id; 124 | 125 | return { 126 | token: result.token, 127 | guestReference: result.user, 128 | docID, 129 | roomID, 130 | }; 131 | } 132 | 133 | // The generic function 134 | const url = strategy; 135 | const res = await fetch(url, { 136 | method: 'POST', 137 | headers: { 138 | 'Content-Type': 'application/json', 139 | }, 140 | body: JSON.stringify({ 141 | resources: [ 142 | { 143 | object: 'document', 144 | reference: document, 145 | permission: 'read_write', 146 | room: room, 147 | }, 148 | { 149 | object: 'room', 150 | reference: room, 151 | permission: 'join', 152 | }, 153 | ], 154 | }), 155 | }); 156 | 157 | if (res.status === 401) { 158 | throw new Error('The Auth Webhook returned unauthorized.'); 159 | } 160 | if (res.status !== 200) { 161 | throw new Error('The Auth Webhook returned a status code other than 200.'); 162 | } 163 | 164 | const json = (await res.json()) as ServerSession; 165 | const { resources, token, user } = json; 166 | 167 | if (!resources || !token || !user) { 168 | if ((json as any).body === 'Unauthorized') { 169 | throw new Error( 170 | 'The Auth Webhook unexpectedly return unauthorized. You may be using an invalid API key.' 171 | ); 172 | } 173 | 174 | throw new Error( 175 | 'The Auth Webhook has an incorrectly formatted JSON response.' 176 | ); 177 | } 178 | 179 | const docID = resources.find((r) => r.object === 'document')!.id; 180 | const roomID = resources.find((r) => r.object === 'room')!.id; 181 | 182 | return { 183 | token, 184 | guestReference: user, 185 | docID, 186 | roomID, 187 | }; 188 | } 189 | -------------------------------------------------------------------------------- /src/throttle.ts: -------------------------------------------------------------------------------- 1 | export function throttleByFirstArgument( 2 | callback: T, 3 | wait: number, 4 | immediate = false 5 | ): T { 6 | // @ts-ignore 7 | let timeouts = {} as any; 8 | let initialCall = true; 9 | 10 | return function () { 11 | const callNow = immediate && initialCall; 12 | const next = () => { 13 | // @ts-ignore 14 | callback.apply(this, arguments); 15 | 16 | // @ts-ignore 17 | timeouts[arguments[0]] = null; 18 | }; 19 | 20 | if (callNow) { 21 | initialCall = false; 22 | next(); 23 | } 24 | 25 | // @ts-ignore 26 | if (!timeouts[arguments[0]]) { 27 | timeouts[arguments[0]] = setTimeout(next, wait); 28 | } 29 | } as any; 30 | } 31 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ReverseTree } from '@roomservice/core/dist/ReverseTree'; 2 | 3 | export interface Ref { 4 | type: 'map' | 'list'; 5 | ref: string; 6 | } 7 | 8 | export interface Tombstone { 9 | t: ''; 10 | } 11 | 12 | export type NodeValue = string | Ref | Tombstone; 13 | 14 | export interface ListCheckpoint { 15 | afters: string[]; 16 | ids: string[]; 17 | values: string[]; 18 | } 19 | 20 | export type MapCheckpoint = { [key: string]: NodeValue }; 21 | 22 | // A previous state of the document that came from superlume 23 | export interface DocumentCheckpoint { 24 | id: string; 25 | index: number; 26 | api_version: number; 27 | vs: string; 28 | actors: { [key: number]: string }; 29 | lists: { [key: string]: ListCheckpoint }; 30 | maps: { [key: string]: MapCheckpoint }; 31 | } 32 | 33 | export interface PresenceObject { 34 | expAt: Date; 35 | value: T; 36 | } 37 | 38 | export type PresenceCheckpoint = { [key: string]: PresenceObject }; 39 | 40 | export interface Message { 41 | ref: string; 42 | type: string; 43 | version: number; 44 | body: T; 45 | } 46 | 47 | // A response that the server has forwarded 48 | // us that originated in another client 49 | export interface FwdResponse { 50 | version: number; 51 | type: 'fwd'; 52 | body: { 53 | from: string; 54 | room: string; 55 | args: string[]; 56 | }; 57 | } 58 | 59 | export interface ErrorResponse { 60 | version: number; 61 | type: 'error'; 62 | body: { 63 | request: string; 64 | message: string; 65 | }; 66 | } 67 | 68 | export type Response = ErrorResponse | FwdResponse; 69 | 70 | export interface Document { 71 | lists: { [key: string]: ReverseTree }; 72 | maps: { [key: string]: { [key: string]: any } }; 73 | localIndex: number; 74 | } 75 | 76 | type RequireSome = Partial> & 77 | Required>; 78 | 79 | export type WebSocketLikeConnection = RequireSome< 80 | WebSocket, 81 | 'send' | 'onmessage' | 'close' 82 | >; 83 | 84 | export interface DocumentContext { 85 | lists: { [key: string]: ReverseTree }; 86 | maps: { [key: string]: { [key: string]: any } }; 87 | localIndex: number; 88 | actor: string; 89 | id: string; 90 | } 91 | 92 | // Utility type to get the type of property 93 | export type Prop = V[K]; 94 | 95 | export interface ObjectClient { 96 | id: string; 97 | dangerouslyUpdateClientDirectly( 98 | msg: any, 99 | versionstamp: string, 100 | ack: boolean 101 | ): ObjectClient; 102 | } 103 | 104 | export interface Resource { 105 | id: string; 106 | object: string; 107 | reference: string; 108 | permission: 'read_write' | 'join'; 109 | } 110 | 111 | export interface AuthResponse { 112 | token: string; 113 | user: string; 114 | resources: Resource[]; 115 | } 116 | 117 | export type AuthFunction = (params: { 118 | room: string; 119 | ctx: T; 120 | }) => Promise; 121 | export type AuthStrategy = string | AuthFunction; 122 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { DocumentCheckpoint } from './types'; 2 | 3 | export function unescapeID(checkpoint: DocumentCheckpoint, id: string): string { 4 | if (id === 'root') return 'root'; 5 | let [index, a] = id.split(':'); 6 | return index + ':' + checkpoint.actors[parseInt(a)]; 7 | } 8 | 9 | export function delay(ms: number) { 10 | return new Promise((resolve) => setTimeout(resolve, ms)); 11 | } 12 | -------------------------------------------------------------------------------- /src/ws.test.ts: -------------------------------------------------------------------------------- 1 | import { mockSessionFetch } from './RoomClient.test'; 2 | import { Prop } from './types'; 3 | import { 4 | BootstrapFetch, 5 | ReconnectingWebSocket, 6 | WebsocketDispatch, 7 | WebSocketFactory, 8 | } from './ws'; 9 | import { mockAuthBundle } from './remote.test'; 10 | 11 | function makeTestWSFactory( 12 | send: Prop, 13 | onmessage: Prop 14 | ): WebSocketFactory { 15 | return async function (_: string) { 16 | return { 17 | onerror: jest.fn(), 18 | send, 19 | onmessage, 20 | onclose: jest.fn(), 21 | close: jest.fn(), 22 | }; 23 | }; 24 | } 25 | 26 | function mockDispatch(): WebsocketDispatch { 27 | return { 28 | forwardCmd: jest.fn(), 29 | bootstrap: jest.fn(), 30 | startQueueingCmds: jest.fn(), 31 | }; 32 | } 33 | 34 | function mockReconnectingWS( 35 | send: Prop, 36 | onmessage: Prop, 37 | fetch: BootstrapFetch 38 | ): ReconnectingWebSocket { 39 | return new ReconnectingWebSocket({ 40 | dispatcher: mockDispatch(), 41 | wsURL: 'wss://ws.invalid', 42 | docsURL: 'https://docs.invalid', 43 | presenceURL: 'https://presence.invalid', 44 | room: 'mock-room', 45 | document: 'default', 46 | authBundle: mockAuthBundle(), 47 | sessionFetch: mockSessionFetch, 48 | wsFactory: makeTestWSFactory(send, onmessage), 49 | bootstrapFetch: fetch, 50 | }); 51 | } 52 | 53 | test('Reconnecting WS sends handshake message using ws factory', async (done) => { 54 | const [send, sendDone] = awaitFnNTimes(1); 55 | const onmessage = jest.fn(); 56 | const fetch = jest.fn(); 57 | 58 | mockReconnectingWS(send, onmessage, fetch); 59 | 60 | await sendDone; 61 | expect(JSON.parse(send.mock.calls[0][0])['type']).toEqual( 62 | 'guest:authenticate' 63 | ); 64 | 65 | done(); 66 | }); 67 | 68 | function awaitFnNTimes(n: number): [jest.Mock, Promise] { 69 | let resolve: any = null; 70 | const p: Promise = new Promise((res) => { 71 | resolve = res; 72 | }); 73 | let count = 0; 74 | 75 | return [ 76 | jest.fn(() => { 77 | count++; 78 | if (count == n) { 79 | resolve(); 80 | } 81 | }), 82 | p, 83 | ]; 84 | } 85 | -------------------------------------------------------------------------------- /src/ws.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebSocketServerMessage, 3 | WebSocketDocFwdMessage, 4 | WebSocketClientMessage, 5 | WebSocketDocCmdMessage, 6 | WebSocketPresenceCmdMessage, 7 | WebSocketPresenceFwdMessage, 8 | WebSocketLeaveMessage, 9 | WebSocketJoinMessage, 10 | } from './wsMessages'; 11 | import { WebSocketLikeConnection, Prop } from './types'; 12 | import { 13 | AuthBundle, 14 | BootstrapState, 15 | fetchBootstrapState, 16 | fetchSession, 17 | LocalSession, 18 | } from './remote'; 19 | import { delay } from './util'; 20 | type Cb = (body: any) => void; 21 | 22 | const WEBSOCKET_TIMEOUT = 1000 * 2; 23 | 24 | const MAX_UNSENT_DOC_CMDS = 10_000; 25 | 26 | const FORWARDED_TYPES = ['doc:fwd', 'presence:fwd', 'room:rm_guest']; 27 | type DocumentBody = Prop; 28 | type PresenceBody = Prop; 29 | 30 | export class ReconnectingWebSocket implements SuperlumeSend { 31 | private wsURL: string; 32 | private docsURL: string; 33 | private presenceURL: string; 34 | private room: string; 35 | private document: string; 36 | 37 | private session?: LocalSession; 38 | private authBundle: AuthBundle; 39 | 40 | private wsFactory: WebSocketFactory; 41 | private bootstrapFetch: BootstrapFetch; 42 | private sessionFetch: SessionFetch; 43 | 44 | // Invariant: at most 1 of current/pendingConn are present 45 | private currentConn?: WebSocketLikeConnection; 46 | private pendingConn?: Promise; 47 | 48 | private dispatcher: WebsocketDispatch; 49 | private callbacks: { [key: string]: Array } = {}; 50 | 51 | constructor(params: { 52 | dispatcher: WebsocketDispatch; 53 | wsURL: string; 54 | docsURL: string; 55 | presenceURL: string; 56 | room: string; 57 | document: string; 58 | 59 | authBundle: AuthBundle; 60 | 61 | wsFactory?: WebSocketFactory; 62 | bootstrapFetch?: BootstrapFetch; 63 | sessionFetch?: SessionFetch; 64 | }) { 65 | this.dispatcher = params.dispatcher; 66 | this.wsURL = params.wsURL; 67 | this.docsURL = params.docsURL; 68 | this.presenceURL = params.presenceURL; 69 | this.room = params.room; 70 | this.document = params.document; 71 | this.authBundle = params.authBundle; 72 | this.wsFactory = params.wsFactory || openWS; 73 | this.bootstrapFetch = params.bootstrapFetch || fetchBootstrapState; 74 | this.sessionFetch = params.sessionFetch || fetchSession; 75 | 76 | this.wsLoop(); 77 | } 78 | 79 | close() { 80 | if (this.currentConn) { 81 | this.currentConn.onmessage = null; 82 | this.currentConn.onclose = null; 83 | this.currentConn.close(); 84 | this.currentConn = undefined; 85 | } 86 | 87 | if (this.pendingConn) { 88 | this.pendingConn = undefined; 89 | } 90 | 91 | this.dispatcher.startQueueingCmds(); 92 | } 93 | 94 | // one-off attempt to connect and authenticate 95 | private async connectAndAuth(): Promise { 96 | if (!this.session) { 97 | this.session = await this.sessionFetch({ 98 | authBundle: this.authBundle, 99 | room: this.room, 100 | document: this.document, 101 | }); 102 | } 103 | const session = this.session!; 104 | const ws = await this.wsFactory(this.wsURL); 105 | ws.onmessage = (ev) => { 106 | const msg = JSON.parse(ev.data) as WebSocketServerMessage; 107 | this.dispatch(msg.type, msg.body); 108 | }; 109 | ws.onclose = () => this.close(); 110 | return Promise.resolve(ws).then(async (ws) => { 111 | ws.send(this.serializeMsg('guest:authenticate', session.token)); 112 | await this.once('guest:authenticated'); 113 | 114 | ws.send(this.serializeMsg('room:join', session.roomID)); 115 | await this.once('room:joined'); 116 | 117 | const bootstrapState = await this.bootstrapFetch({ 118 | docID: session.docID, 119 | roomID: session.roomID, 120 | docsURL: this.docsURL, 121 | presenceURL: this.presenceURL, 122 | token: session.token, 123 | }); 124 | 125 | this.dispatcher.bootstrap(session.guestReference, bootstrapState); 126 | 127 | return ws; 128 | }); 129 | } 130 | 131 | // main logic to obtain an active and auth'd connection 132 | private async conn(): Promise { 133 | if (this.currentConn) { 134 | return this.currentConn; 135 | } 136 | 137 | if (this.pendingConn) { 138 | return this.pendingConn; 139 | } 140 | 141 | this.close(); 142 | this.pendingConn = (async () => { 143 | let delayMs = 0; 144 | let maxDelayMs = 60 * 1000; 145 | 146 | while (true) { 147 | const jitteredDelay = (delayMs * (Math.random() + 1)) / 2; 148 | await delay(jitteredDelay); 149 | delayMs = Math.min(2 * delayMs + 100, maxDelayMs); 150 | 151 | try { 152 | let ws = await this.connectAndAuth(); 153 | this.currentConn = ws; 154 | this.pendingConn = undefined; 155 | return ws; 156 | } catch (err) { 157 | console.error( 158 | 'Connection to RoomService failed with', 159 | err, 160 | '\nRetrying...' 161 | ); 162 | } 163 | } 164 | })(); 165 | 166 | return this.pendingConn; 167 | } 168 | 169 | private async wsLoop() { 170 | while (true) { 171 | await this.conn(); 172 | this.processSendQueue(); 173 | await delay(1000); 174 | } 175 | } 176 | 177 | private lastTime: number = 0; 178 | private msgsThisMilisecond: number = 0; 179 | 180 | private timestamp() { 181 | const time = Date.now(); 182 | if (time === this.lastTime) { 183 | this.msgsThisMilisecond++; 184 | } else { 185 | this.lastTime = time; 186 | this.msgsThisMilisecond = 0; 187 | } 188 | return `${time}:${this.msgsThisMilisecond}`; 189 | } 190 | 191 | serializeMsg( 192 | msgType: 'room:join', 193 | room: Prop 194 | ): string; 195 | serializeMsg(msgType: 'guest:authenticate', token: string): string; 196 | serializeMsg( 197 | msgType: 'doc:cmd', 198 | body: Prop 199 | ): string; 200 | serializeMsg( 201 | msgType: 'presence:cmd', 202 | body: Prop 203 | ): string; 204 | serializeMsg( 205 | msgType: Prop, 206 | body: any 207 | ): string { 208 | const ts = this.timestamp(); 209 | const msg: WebSocketClientMessage = { 210 | type: msgType, 211 | ts, 212 | ver: 0, 213 | body, 214 | }; 215 | 216 | return JSON.stringify(msg); 217 | } 218 | 219 | private docCmdSendQueue: Array = []; 220 | 221 | // only most recent presence cmd per-key is kept 222 | private presenceCmdSendQueue = new Map(); 223 | 224 | send(msgType: 'doc:cmd', body: DocumentBody): void; 225 | send(msgType: 'presence:cmd', body: PresenceBody): void; 226 | send(msgType: Prop, body: any): void { 227 | if (msgType == 'doc:cmd') { 228 | if (this.docCmdSendQueue.length >= MAX_UNSENT_DOC_CMDS) { 229 | throw 'RoomService send queue full'; 230 | } 231 | const docBody = body as DocumentBody; 232 | this.docCmdSendQueue.push(docBody); 233 | } 234 | 235 | if (msgType == 'presence:cmd') { 236 | let presenceBody = body as PresenceBody; 237 | this.presenceCmdSendQueue.set(presenceBody.key, body as PresenceBody); 238 | } 239 | 240 | this.processSendQueue(); 241 | } 242 | 243 | private processSendQueue() { 244 | if (!this.currentConn || !this.session) { 245 | return; 246 | } 247 | 248 | try { 249 | while (this.presenceCmdSendQueue.size > 0) { 250 | const first = this.presenceCmdSendQueue.entries().next(); 251 | if (first) { 252 | const [key, msg] = first.value; 253 | const json = this.serializeMsg('presence:cmd', msg); 254 | this.currentConn.send(json); 255 | this.presenceCmdSendQueue.delete(key); 256 | } 257 | } 258 | 259 | while (this.docCmdSendQueue.length > 0) { 260 | const msg = this.docCmdSendQueue[0]; 261 | const json = this.serializeMsg('doc:cmd', msg); 262 | this.currentConn.send(json); 263 | this.docCmdSendQueue.splice(0, 1); 264 | } 265 | } catch (e) { 266 | console.error(e); 267 | } 268 | } 269 | 270 | private bind( 271 | msgType: 'room:rm_guest', 272 | callback: (body: Prop) => void 273 | ): Cb; 274 | private bind(msgType: 'room:joined', callback: (body: string) => void): Cb; 275 | private bind( 276 | msgType: 'doc:fwd', 277 | callback: (body: Prop) => void 278 | ): Cb; 279 | private bind( 280 | msgType: 'presence:fwd', 281 | callback: (body: Prop) => void 282 | ): Cb; 283 | private bind( 284 | msgType: 'guest:authenticated', 285 | callback: (body: string) => void 286 | ): Cb; 287 | private bind(msgType: 'error', callback: (body: string) => void): Cb; 288 | private bind( 289 | msgType: Prop, 290 | callback: Cb 291 | ): Cb { 292 | this.callbacks[msgType] = this.callbacks[msgType] || []; 293 | this.callbacks[msgType].push(callback); 294 | return callback; 295 | } 296 | 297 | private unbind(msgType: string, callback: Cb) { 298 | this.callbacks[msgType] = this.callbacks[msgType].filter( 299 | (c) => c !== callback 300 | ); 301 | } 302 | 303 | private dispatch(msgType: string, body: any) { 304 | if (msgType == 'error') { 305 | console.error(body); 306 | } 307 | 308 | const stack = this.callbacks[msgType]; 309 | if (stack) { 310 | for (let i = 0; i < stack.length; i++) { 311 | stack[i](body); 312 | } 313 | } 314 | 315 | if (FORWARDED_TYPES.includes(msgType)) { 316 | this.dispatcher.forwardCmd(msgType, body); 317 | } 318 | } 319 | 320 | private async once(msg: string) { 321 | let off: (args: any) => any; 322 | return Promise.race([ 323 | new Promise((_, reject) => 324 | setTimeout(() => reject('timeout'), WEBSOCKET_TIMEOUT) 325 | ), 326 | new Promise((resolve) => { 327 | off = this.bind(msg as any, (body) => { 328 | resolve(body); 329 | }); 330 | }), 331 | ]).then(() => { 332 | if (off) this.unbind(msg, off); 333 | }); 334 | } 335 | } 336 | 337 | export type ForwardedMessageBody = 338 | | Prop 339 | | Prop 340 | | Prop; 341 | 342 | export interface WebsocketDispatch { 343 | forwardCmd(type: string, body: ForwardedMessageBody): void; 344 | // NOTE: it's possible for a future call to fetchSession ends up with a 345 | // different userID 346 | bootstrap(actor: string, state: BootstrapState): void; 347 | startQueueingCmds(): void; 348 | } 349 | 350 | async function openWS(url: string): Promise { 351 | return new Promise(function (resolve, reject) { 352 | var ws = new WebSocket(url); 353 | ws.onopen = function () { 354 | resolve(ws); 355 | }; 356 | ws.onerror = function (err) { 357 | reject(err); 358 | }; 359 | }); 360 | } 361 | 362 | export interface SuperlumeSend { 363 | send(msgType: 'doc:cmd', body: DocumentBody): void; 364 | send(msgType: 'presence:cmd', body: PresenceBody): void; 365 | send(msgType: Prop, body: any): void; 366 | } 367 | 368 | export type WebSocketFactory = (url: string) => Promise; 369 | 370 | export type WebSocketTransport = Pick< 371 | WebSocket, 372 | 'send' | 'onclose' | 'onmessage' | 'onerror' | 'close' 373 | >; 374 | 375 | export type BootstrapFetch = (props: { 376 | docsURL: string; 377 | presenceURL: string; 378 | token: string; 379 | roomID: string; 380 | docID: string; 381 | }) => Promise; 382 | 383 | export type SessionFetch = (params: { 384 | authBundle: AuthBundle; 385 | room: string; 386 | document: string; 387 | }) => Promise; 388 | -------------------------------------------------------------------------------- /src/wsMessages.ts: -------------------------------------------------------------------------------- 1 | export interface WebSocketAuthenticateMessage { 2 | ver: number; 3 | type: 'guest:authenticate'; 4 | body: string; 5 | ts: string; 6 | } 7 | 8 | export interface WebSocketAuthenticatedMessage { 9 | ver: number; 10 | type: 'guest:authenticated'; 11 | body: string; 12 | } 13 | 14 | export interface WebSocketJoinMessage { 15 | ver: number; 16 | type: 'room:join'; 17 | body: string; 18 | ts: string; 19 | } 20 | 21 | export interface WebSocketJoinedMessage { 22 | ver: number; 23 | type: 'room:joined'; 24 | body: string; 25 | } 26 | 27 | export interface WebSocketDocCmdMessage { 28 | ver: number; 29 | type: 'doc:cmd'; 30 | body: { 31 | room: string; 32 | args: string[]; 33 | }; 34 | ts: string; 35 | } 36 | 37 | export interface WebSocketDocFwdMessage { 38 | ver: number; 39 | type: 'doc:fwd'; 40 | body: { 41 | from: string; // a guest id 42 | room: string; 43 | vs: string; 44 | args: string[]; 45 | ack: boolean; 46 | }; 47 | } 48 | 49 | export interface WebSocketPresenceCmdMessage { 50 | ver: number; 51 | type: 'presence:cmd'; 52 | body: { 53 | room: string; 54 | key: string; 55 | value: string; 56 | expAt: number; // unix timestamp 57 | }; 58 | ts: string; 59 | } 60 | 61 | export interface WebSocketLeaveMessage { 62 | ver: number; 63 | type: 'room:rm_guest'; 64 | body: { 65 | guest: string; // a guest ref 66 | room: string; // a room id 67 | }; 68 | } 69 | 70 | export interface WebSocketPresenceFwdMessage { 71 | ver: number; 72 | type: 'presence:fwd'; 73 | body: { 74 | from: string; // a guest ref 75 | room: string; 76 | key: string; 77 | value: string; 78 | expAt: number; // unix timestamp 79 | }; 80 | } 81 | 82 | export interface WebSocketErrorMessage { 83 | ver: number; 84 | type: 'error'; 85 | body: { 86 | request: string; 87 | message: string; 88 | }; 89 | } 90 | 91 | // Messages coming from the server 92 | export type WebSocketServerMessage = 93 | | WebSocketAuthenticatedMessage 94 | | WebSocketDocFwdMessage 95 | | WebSocketPresenceFwdMessage 96 | | WebSocketJoinedMessage 97 | | WebSocketErrorMessage 98 | | WebSocketLeaveMessage; 99 | 100 | // Messages coming from the client 101 | export type WebSocketClientMessage = 102 | | WebSocketAuthenticateMessage 103 | | WebSocketDocCmdMessage 104 | | WebSocketPresenceCmdMessage 105 | | WebSocketJoinMessage; 106 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": false, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------