Skip to content

Commit d773276

Browse files
pyphiliakim
andauthored
feat: setup yjs for collaboration in pages (#1959)
* feat: setup yjs for collaboration in pages * refactor: add tests, simplify code * fix: fix syntax issues * refactor: apply pr requested changes * refactor: fix tests --------- Co-authored-by: kim <kim.phanhoang@epfl.ch>
1 parent 3ce0469 commit d773276

7 files changed

Lines changed: 605 additions & 9 deletions

File tree

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"i18next": "24.2.3",
9292
"ioredis": "5.6.1",
9393
"jsonwebtoken": "9.0.2",
94+
"lib0": "0.2.114",
9495
"lodash.groupby": "4.6.0",
9596
"lodash.partition": "4.6.0",
9697
"lodash.uniqby": "4.7.0",
@@ -119,7 +120,10 @@
119120
"url-http": "1.3.0",
120121
"uuid": "11.1.0",
121122
"ws": "8.18.1",
122-
"yazl": "3.3.1"
123+
"y-leveldb": "0.2.0",
124+
"y-protocols": "1.0.6",
125+
"yazl": "3.3.1",
126+
"yjs": "13.6.27"
123127
},
124128
"devDependencies": {
125129
"@commitlint/cli": "19.8.0",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Pages
2+
3+
## Collaboration
4+
5+
Collaboration is handled by yjs.js
6+
7+
The server files take their sources from this [example repository](https://github.com/yjs/y-websocket-server/).

src/services/item/plugins/page/page.controller.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { eq } from 'drizzle-orm';
22
import { StatusCodes } from 'http-status-codes';
3+
import { AddressInfo } from 'net';
4+
import { v4 } from 'uuid';
5+
import { WebSocket } from 'ws';
36

47
import type { FastifyInstance } from 'fastify';
58

6-
import { FolderItemFactory, HttpMethod } from '@graasp/sdk';
9+
import { FolderItemFactory, HttpMethod, PermissionLevel } from '@graasp/sdk';
710

811
import build, {
912
clearDatabase,
@@ -72,4 +75,56 @@ describe('Page routes tests', () => {
7275
});
7376
});
7477
});
78+
79+
describe('GET /items/pages/ws', () => {
80+
it('Throws if signed out', async () => {
81+
const response = await app.inject({
82+
method: HttpMethod.Get,
83+
url: { protocol: 'ws', pathname: `/items/pages/${v4()}/ws` },
84+
});
85+
86+
expect(response.statusCode).toBe(StatusCodes.UNAUTHORIZED);
87+
});
88+
89+
it('Throws if id is incorrect', async () => {
90+
const response = await app.inject({
91+
method: HttpMethod.Get,
92+
path: {
93+
protocol: 'ws',
94+
pathname: '/items/pages/wrong-id/ws',
95+
},
96+
});
97+
98+
expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST);
99+
});
100+
101+
it('Allow access', async () => {
102+
const {
103+
actor,
104+
items: [item],
105+
} = await seedFromJson({
106+
items: [{ memberships: [{ account: 'actor', permission: PermissionLevel.Write }] }],
107+
});
108+
assertIsDefined(actor);
109+
mockAuthenticate(actor);
110+
111+
// start server to correctly listen to websockets
112+
await app.listen();
113+
await app.ready();
114+
const port = (app.server.address() as AddressInfo)!.port;
115+
const ws = new WebSocket(`http://localhost:${port}/items/pages/${item.id}/ws`);
116+
117+
await new Promise((done, reject) => {
118+
ws.on('error', (e) => {
119+
console.log(e);
120+
reject(new Error('should not throw'));
121+
});
122+
123+
ws.on('message', () => {
124+
// should be able to receive messages
125+
done(true);
126+
});
127+
});
128+
});
129+
});
75130
});

src/services/item/plugins/page/page.controller.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import { StatusCodes } from 'http-status-codes';
22

33
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox';
44

5+
import { PermissionLevel } from '@graasp/sdk';
6+
57
import { resolveDependency } from '../../../../di/utils';
68
import { db } from '../../../../drizzle/db';
79
import { asDefined } from '../../../../utils/assertions';
810
import { isAuthenticated, matchOne } from '../../../auth/plugins/passport';
911
import { assertIsMember } from '../../../authentication';
12+
import { AuthorizedItemService } from '../../../authorizedItem.service';
1013
import { validatedMemberAccountRole } from '../../../member/strategies/validatedMemberAccountRole';
11-
import { createPage } from './page.schemas';
14+
import { createPage, pageWebsocketsSchema } from './page.schemas';
1215
import { PageItemService } from './page.service';
16+
import { setupWSConnection } from './setupWSConnection';
1317

1418
export const pageItemPlugin: FastifyPluginAsyncTypebox = async (fastify) => {
1519
const pageItemService = resolveDependency(PageItemService);
20+
const authorizedItemService = resolveDependency(AuthorizedItemService);
1621

1722
fastify.post(
1823
'/pages',
@@ -42,4 +47,31 @@ export const pageItemPlugin: FastifyPluginAsyncTypebox = async (fastify) => {
4247
reply.send(item);
4348
},
4449
);
50+
51+
fastify.get(
52+
'/pages/:id/ws',
53+
{
54+
websocket: true,
55+
schema: pageWebsocketsSchema,
56+
preHandler: [
57+
isAuthenticated,
58+
matchOne(validatedMemberAccountRole),
59+
async ({ user, params }) => {
60+
const account = asDefined(user?.account);
61+
62+
// check write permission
63+
await authorizedItemService.assertAccessForItemId(db, {
64+
permission: PermissionLevel.Write,
65+
itemId: params.id,
66+
accountId: account.id,
67+
});
68+
},
69+
],
70+
},
71+
async (client, req) => {
72+
client.on('error', fastify.log.error);
73+
74+
setupWSConnection(client, req);
75+
},
76+
);
4577
};

src/services/item/plugins/page/page.schemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,14 @@ export const createPage = {
3939
]),
4040
response: { [StatusCodes.CREATED]: pageSchema, '4xx': errorSchemaRef },
4141
} as const satisfies FastifySchema;
42+
43+
export const pageWebsocketsSchema = {
44+
operationId: 'pagesWebsockets',
45+
tags: ['item', 'page', 'websockets'],
46+
summary: 'Connect to websockets for a page',
47+
description: 'Connect to websockets for a page and allow collaboration through yjs.',
48+
49+
params: customType.StrictObject({
50+
id: customType.UUID(),
51+
}),
52+
} as const satisfies FastifySchema;

0 commit comments

Comments
 (0)