├── .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 |
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 |
--------------------------------------------------------------------------------